前段时间写了一篇spring security的详细入门,但是没有联系实际。
所以这次在真实的项目中来演示一下怎样使用springsecurity来实现我们最常用的登录校验。本次演示使用现在市面上最常见的开发方式,前后端分离开发。前端使用vue3进行构建,用到了element-plus组件库、axios封装、pinia状态管理、Router路由跳转等技术。后端还是spring boot整合springsecurity+JWT来实现登录校验。
本文适合有一定基础的人来看,如果你对springsecurity安全框架还不是很了解,建议你先去看一下我之前写过的spring security框架的快速入门:
springboot3整合SpringSecurity实现登录校验与权限认证(万字超详细讲解)
技术栈版本:vue3.3.11、springboot3.1.5、spring security6.x
业务流程:
可以看到整个业务的流程还是比较简单的,那么接下来就基于这个业务流程来进行我们具体代码的编写和实现;
前端:
新建一个vue项目,并引入一些具体的依赖;我们本次项目用到的有:element-plus、axios、pinia状态管理、Router路由跳转(注意我们在项目中使用到的pinia要引入持久化插件)
vue3中pinia的使用及持久化(详细解释)
在vue项目中新建两个组件:Login.vue(登录组件,负责登录页面的展示)、Layout.vue(布局页面,负责整体项目的布局,登录成功之后就是跳转到这个页面)
路由的定义:在router文件夹下新建index.ts文件
import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'login', component: () => import('@/components/Login.vue') }, { path: '/layout', name: 'layout', component: () => import('@/components/Layout.vue') } ] }) export default router
定义Login登录组件为默认的组件,并定义Layout组件;
useToken的状态封装:在stoers文件夹下新建useToken.ts
import { defineStore } from 'pinia' import { ref } from 'vue' const useTokenStore = defineStore('token', ()=>{ const token=ref() const removeToken=()=>{ token.value='' } return {token,removeToken} }, {persist: true} ) export default useTokenStore
axios的封装:在utils文件夹在新建request.ts文件
import axios from "axios"; import useTokenStore from '@/stores/useToken' import { ElMessage } from 'element-plus'; // 先建一个api const api = axios.create({ baseURL: "http://localhost:8888", timeout: 5000 }); // 发送请求前拦截 api.interceptors.request.use( config =>{ const useToken = useTokenStore(); if(useToken.token){ console.log("请求头toekn=====>", useToken.token); // 设置请求头 // config.headers['token'] = useToken.token; config.headers.token = useToken.token; } return config; }, error =>{ return Promise.reject(error); } ) // 响应前拦截 api.interceptors.response.use( response =>{ console.log("响应数据", response); if(response.data.code !=200){ ElMessage.error(response.data.message); } return response; }, error =>{ return Promise.reject(error); } ) export default api;
在请求前拦截,主要是为了在请求头中新增token。在request.ts中引入了useToken,并判断如果token不为空,那么在请求头中新增token。
在响应前也进行了一次拦截,如果后端返回的状态码不为200,那么就打印出错误信息;
接下来就可以在Login.vue中进行我们的登录逻辑的具体编写了(我直接将组件内容进行复制了,也不是什么太难的东西,主要还是element-plus的表单):
<template> <div class="background" style="font-family:kaiti" > <!-- 注册表单 --> <el-dialog v-model="isRegister" title="用户注册" width="30%"> <el-form label-width="120px" v-model="registerForm"> <el-form-item label="用户名"> <el-input type="text" v-model="registerForm.username" > <template #prefix> <el-icon><Avatar /></el-icon> </template> </el-input> </el-form-item> <el-form-item label="密码"> <el-input type="password" v-model="registerForm.password" > <template #prefix> <el-icon><Lock /></el-icon> </template> </el-input> </el-form-item> <el-form-item> <el-button type="primary" @click="registerAdd" >提交</el-button> <el-button @click="isRegister = false">取消</el-button> </el-form-item> </el-form> </el-dialog> <!-- 登陆框 --> <div class="login-box"> <el-form label-width="100px" :model="loginFrom" style="max-width: 460px" :rules="Loginrules" ref="ruleFormRef" > <el-form-item label="用户名" prop="username"> <el-input v-model="loginFrom.username" clearable > <template #prefix> <el-icon><Avatar /></el-icon> </template> </el-input> </el-form-item> <el-form-item label="密码" prop="password"> <el-input v-model="loginFrom.password" show-password clearable type="password" > <template #prefix> <el-icon><Lock /></el-icon> </template> </el-input> </el-form-item> <el-form-item label="验证码" prop="codeValue"> <el-input v-model="loginFrom.codeValue" style="width: 100px;" clearable > </el-input> <img :src="codeImage" @click="getCode" style="transform: scale(0.9);"/> </el-form-item> <el-button type="success" @click="getLogin(ruleFormRef)" style="transform: translateX(50px)" class="my-button">登录</el-button> <el-button type="primary" @click="isRegister=true" class="my-button">注册</el-button> </el-form> </div> </div> </template> <script lang="ts" setup> import { ref,onMounted,reactive } from 'vue'; import { useRouter } from 'vue-router'; import { ElMessage } from 'element-plus'; import useTokenStore from '@/stores/useToken' import api from '@/utils/request' import type { FormInstance, FormRules } from 'element-plus' const ruleFormRef = ref<FormInstance>() const loginFrom=ref({ username:'', password:'', codeKey:'', codeValue:'' }) const Loginrules=reactive({ username: [ { required: true, message: '请输入用户名', trigger: 'blur' } ], password: [ { required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, max: 12, message: '长度在 6 到 12 个字符', trigger: 'blur'} ], codeValue: [ { required: true, message: '请输入验证码', trigger: 'blur' } ] }) const registerForm=ref({ username:'', password:'' }) const codeImage=ref('') const isRegister=ref(false) const tokenStore = useTokenStore(); const router = useRouter() const getLogin = async(formEl: FormInstance | undefined) => { if (!formEl) return await formEl.validate((valid, fields) => { if (valid) { console.log('submit!') } else { ElMessage('请输入完整信息') return; } }) let {data}=await api.post('/user/login',loginFrom.value) if(data.code==200){ ElMessage('登录成功') console.log(data); tokenStore.token=data.data router.replace({name:'layout'}) }else{ ElMessage('登录失败') } } const getCode=async()=>{ let {data}=await api.get('/getCaptcha') loginFrom.value.codeKey=data.data.codeKey codeImage.value=data.data.codeValue } const registerAdd=async()=>{ let {data}=await api.post('/user/register',registerForm.value) if(data.code==200){ ElMessage('注册成功') isRegister.value=false }else{ ElMessage('注册失败') isRegister.value=false } } // 页面加载完成获取验证码 onMounted(()=>{ getCode() }) </script>
这个页面中,我还加入了一个图形验证码。还有一个注册的表单。其他的就和普通的登录一样了;
这个页面的最终效果如图:
Layout.vue页面中,我们只进行两个方法的测试;一个是获取当前用户的具体信息,一个是退出登录的按钮;
<template> <div class="common-layout"> <el-container> <el-header height="100px"> 头部 <el-button type="primary" @click="getUserInfo">获取用户信息</el-button> <el-button type="success" @click="Logout">退出登录</el-button> </el-header> <el-container> <el-aside width="200px"> 菜单栏 </el-aside> <el-main> 展示区 </el-main> </el-container> </el-container> </div> </template> <script lang="ts" setup name="Layout"> import { ref } from 'vue' import api from '@/utils/request' import {ElMessage} from 'element-plus' import { useRouter } from 'vue-router' import useToeknStore from '@/stores/useToken' const router = useRouter() const Logout =async () => { let data= api.get("/user/logout") if(data.data.code==200){ ElMessage.success('退出成功') // 清除token useToeknStore().removeToken router.replace({name:'login'}) } else{ ElMessage.error('退出失败') } } const getUserInfo = async() => { let data=await api.get("/user/info") console.log('@',data); } </script>
数据库:
我新建一个数据表,用于登录校验:
CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, status INT DEFAULT 0 );
这张表中只有简单的用户名,密码,和用户是否过期等字段;
后端:
新建一个spring boot项目,并导入以下的依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.3.0</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.18</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>2.0.21</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
后端使用MybatisPlus做用户的增、删、改、查等。基础的controller、service、mapper,我就不再这里进行赘述了;
新建一个类MyTUserDetail ,继承UserDetail:
@Data public class MyTUserDetail implements Serializable, UserDetails { private static final long serialVersionUID = 1L; private Users Users; @JsonIgnore //json忽略 @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } @JsonIgnore @Override public String getPassword() { return this.getUsers().getPassword(); } @JsonIgnore @Override public String getUsername() { return this.getUsers().getUsername(); } @JsonIgnore @Override public boolean isAccountNonExpired() { return this.getUsers().getStatus()==0; } @JsonIgnore @Override public boolean isAccountNonLocked() { return this.getUsers().getStatus()==0; } @JsonIgnore @Override public boolean isCredentialsNonExpired() { return this.getUsers().getStatus()==0; } @JsonIgnore @Override public boolean isEnabled() { return this.getUsers().getStatus()==0; } }
新建一个类MyUserDetailServerImpl,实现MyUserDetailServer接口的loadUserByUsername方法
@Service public class MyUserDetailServerImpl implements MyUserDetailServer { @Autowired UserMapper userService; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userService.selectOne(new LambdaQueryWrapper<User>(). eq(username != null, User::getUsername, username)); if (tUser == null) { throw new UsernameNotFoundException("用户名不存在"); } MyTUserDetail myTUserDetail=new MyTUserDetail(); myTUserDetail.setUser(user); return myTUserDetail; } }
新建一个JwtUtils的工具类,来生成token;
@Component public class JwtUtil { private final String secret="zhangqiao"; private final Long expiration=36000000L; public String generateToken(Integer id) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); Algorithm algorithm = Algorithm.HMAC256(secret); return JWT.create() .withSubject(String.valueOf(id)) .withIssuedAt(now) .withExpiresAt(expiryDate) .sign(algorithm); } public Integer getUsernameFromToken(String token) { try { DecodedJWT jwt = JWT.decode(token); return Integer.valueOf(jwt.getSubject()); } catch (JWTDecodeException e) { return null; } } /* * 判断token是否过期 * */ public boolean isTokenValid(String token) { try { Algorithm algorithm = Algorithm.HMAC256(secret); JWT.require(algorithm).build().verify(token); return true; } catch (Exception e) { return false; } } /* * 刷新token * */ public String refreshToken(String token) { try { DecodedJWT jwt = JWT.decode(token); String username = jwt.getSubject(); Algorithm algorithm = Algorithm.HMAC256(secret); Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); return JWT.create() .withSubject(username) .withIssuedAt(now) .withExpiresAt(expiryDate) .sign(algorithm); } catch (JWTDecodeException e) { return null; } } }
新建一个Jwt的拦截类,继承一个OncePerRequestFilter类,用来在每次请求前拦截请求,并从中获取token,并判断这个token是否是我们用户表中的token;
如果是,那么将用户信息存储到security中,这样后面的过滤器就可以获取到用户信息了,如果不是,那么直接放行。我们会将这个拦截器加入到UsernamePasswordAuthenticationFilter过滤器之前。
@Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private RedisTemplate<String,String> redisTemplate; @Autowired private JwtUtil jwtUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //获取请求头中的token String token = request.getHeader("token"); System.out.println("前端的token信息=======>"+token); //如果token为空直接放行,由于用户信息没有存放在SecurityContextHolder.getContext()中所以后面的过滤器依旧认证失败符合要求 if(!StringUtils.hasText(token)){ filterChain.doFilter(request,response); return; } // 解析Jwt中的用户id Integer userId = jwtUtil.getUsernameFromToken(token); //从redis中获取用户信息 String redisUser = redisTemplate.opsForValue().get(String.valueOf(userId)); if(!StringUtils.hasText(redisUser)){ filterChain.doFilter(request,response); return; } MyTUserDetail myTUserDetail= JSON.parseObject(redisUser, MyTUserDetail.class); //将用户信息存放在SecurityContextHolder.getContext(),后面的过滤器就可以获得用户信息了。这表明当前这个用户是登录过的,后续的拦截器就不用再拦截了 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken=new UsernamePasswordAuthenticationToken(myTUserDetail,null,null); SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); filterChain.doFilter(request,response); } }
security配置类的设置:
(由于我们本次采用前后端分离的方式来进行开发,所以不在需要使用spring security默认提供的formLogin
方法)
formLogin
方法是 Spring Security 中用于配置基于表单的登录认证的一种方式。它通常用于传统的 Web 应用程序,其中前端页面由后端动态生成,并且用户在页面中输入用户名和密码来进行登录。在这种情况下,Spring Security 负责处理登录请求、验证用户身份、生成会话等操作。
但是,在前后端分离的开发模式中,前端和后端是完全分离的,前端负责渲染界面和处理用户交互,后端负责提供 API 接口和数据服务。因此,通常不会使用 formLogin
方法,因为我们的前端不会通过后端渲染的页面来进行登录。后端只需要返回一些相应的数据和状态,有关页面的跳转和渲染是由前端(vue3)来实现的。
@Configuration @EnableWebSecurity public class MyServiceConfig { @Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; /* * security的过滤器链 * */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http)throws Exception { http.csrf(AbstractHttpConfigurer::disable); http.authorizeHttpRequests((auth) -> auth .requestMatchers("/getCaptcha","user/login","user/register").permitAll() .anyRequest().authenticated() ); http.cors(cors->{ cors.configurationSource(corsConfigurationSource()); }); //自定义过滤器放在UsernamePasswordAuthenticationFilter过滤器之前 http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Autowired private MyUserDetailServerImpl myUserDetailsService; /* * 验证管理器 * */ @Bean public AuthenticationManager authenticationManager(PasswordEncoder passwordEncoder){ DaoAuthenticationProvider provider=new DaoAuthenticationProvider(); //将编写的UserDetailsService注入进来 provider.setUserDetailsService(myUserDetailsService); //将使用的密码编译器加入进来 provider.setPasswordEncoder(passwordEncoder); //将provider放置到AuthenticationManager 中 ProviderManager providerManager=new ProviderManager(provider); return providerManager; } //跨域配置 @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList("*")); configuration.setAllowedMethods(Arrays.asList("*")); configuration.setAllowedHeaders(Arrays.asList("*")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } /* * 密码加密器*/ @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } }
在security的配置类中,设置了跨域问题、拦截器链的配置(并将一些需要放行的接口放行,将我们自定义的Jwt拦截器加入了security拦截链)、密码编译器、AuthenticationManager 验证管理等等一系列配置;
Usercontroller控制器:
@RestController @RequestMapping("/user") public class UsersController { @Autowired private IUsersService userService; @Autowired private PasswordEncoder passwordEncoder; @Autowired private RedisTemplate<String,String> redisTemplate; @Autowired private JwtUtils jwtUtils; @PostMapping("/login") public Result<String> login(@RequestBody DtoLogin dtoLogin) { System.out.println(dtoLogin); String token = userService.login(dtoLogin); return Result.successData(token); } @PostMapping("/register") public Result register(@RequestBody DtoLogin dtoLogin) { System.out.println(dtoLogin); Users users = new Users(); users.setUsername(dtoLogin.getUsername()); users.setPassword(passwordEncoder.encode(dtoLogin.getPassword())); userService.save(users); return Result.success(); } @Autowired private RedisTemplate<String,String> redisTemplate; @Autowired private JwtUtil jwtUtil; @GetMapping("/info") public Result info(@RequestHeader("token")String token){ System.out.println("controller层获取到的token=======>"+token); Integer id = jwtUtil.getUsernameFromToken(token); String redisUser = redisTemplate.opsForValue().get(String.valueOf(id)); MyTUserDetail myTUserDetail = JSON.parseObject(redisUser, MyTUserDetail.class); return Result.successData(myTUserDetail); } @GetMapping("user/logout") public Result logout(@RequestHeader("token")String token){ // 解析Jwt中的用户id Integer userId = jwtUtil.getUsernameFromToken(token); //清除SpringSecurity上下文 SecurityContextHolder.clearContext(); //删除redis中存储的用户数据 redisTemplate.delete(Integer.toString(userId)); return Result.success(); } }
在UserController控制器中,由于登录方法比较复杂,我将登录方法重新在service中重写了,剩下的获取用户信息、用户注册、退出登录都直接在UseController中实现了;
service中重写的登录方法:
@Service public class UsersServiceImpl extends ServiceImpl<UsersMapper, Users> implements IUsersService { @Autowired private RedisTemplate<String,String> redisTemplate; @Autowired AuthenticationManager authenticationManager; @Autowired private JwtUtil jwtUtil; @Override public String login(DtoLogin dtoLogin) { String codeRedis = redisTemplate.opsForValue().get(dtoLogin.getCodeKey()); if (!dtoLogin.getCodeValue().equals(codeRedis)){ throw new ResultException(400,"验证码错误"); } // 验证码正确,删除redis中的验证码 redisTemplate.delete(dtoLogin.getCodeKey()); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(dtoLogin.getUsername(),dtoLogin.getPassword()); Authentication authenticate = authenticationManager.authenticate(authenticationToken); if(authenticate==null){ throw new ResultException(400,"用户名或密码错误"); } // 获取返回的用户信息 Object principal = authenticate.getPrincipal(); MyTUserDetail myTUserDetail=(MyTUserDetail) principal; System.out.println(myTUserDetail); // 使用Jwt生成token,并将用户的id传入 String token = jwtUtil.generateToken(myTUserDetail.getUsers().getId()); redisTemplate.opsForValue(). set(String.valueOf(myTUserDetail.getUsers().getId()), JSON.toJSONString(myTUserDetail),1, TimeUnit.DAYS); return token; } }
由于我们还是用了验证码,所以在这个登录方法中先判断了验证码、如果验证码正确。那么在判断传回来的用户名和密码。如果都正确,那么用Jwt返回一个token,token中携带的是用户的id;
至此,我们所有的前后端代码都已经写完了。那么,让我们具体的实验一下;
运行:
由于我刚创建的表,还没有添加数据,那么我现在前端点击注册,写入几条用户信息;
写入信息之后,我使用刚注册过的用户来登录一下:
注册成功之后,就会进入到我们自定义个Layout.vue组件内:
现在,我点击“获取用户信息”按钮,因为这个路径我们并没有放行,那么他访问时就会被我们自定义的Jwt拦截器拦截,并验证它请求头中携带的token是否正确。如果正确,则放行。如果不正确,那么就会放行到登录拦截器中。
可以看到,在控制台中打印出了用户的信息。这是肯定的,因为它这次请求携带的token是正确的,那么如果我们修改一下token的值,他还能正常访问到用户信息这个接口吗?
我修改了请求头中的token信息,可以看到立马这个请求就被拦截了。并爆出了403错误;
现在,我点击“退出登录”按钮,它应该删除useToken中的token值,并且后端也会删除redis中的值,并且跳转到登录页面。后端也会删除redsi中存储的用户数据;
现在,我们所有的任务都已经完成了。
具体的前后端源码放在码云上面了,有需要的可以自行下载:
Vue-Security: 前后端分离的Security
我再整体理一下具体的思路:
前端发送请求后端,如果是登录请求,那么直接走登录接口即可,我将登录接口进行了方行,任何人都可以访问到登录接口,并且执行登录接口的逻辑;如果登录成功,会返回一个token,前后会将这个token存到useToken中,并且再以后的每次请求中都携带token;如果登录失败,返回一个报错信息即可。
如果前端发送的不是登录接口,但是前端携带可正确的token,那么会被我们自定义的Jwt拦截器拦截,并从中读取用户信息,放到security中共后续的拦截器使用;如果没有携带token,或者token不正确,那么后端会直接返回403的状态码提示;
后续:权限校验
前后端分离,使用vue3整合SpringSecurity加JWT实现权限校验-CSDN博客
到此这篇关于vue3整合SpringSecurity加JWT实现登录认证的文章就介绍到这了,更多相关vue3 SpringSecurity登录认证内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
来源链接:https://www.jb51.net/javascript/339520z0b.htm
如有侵犯您的版权,请及时联系3500663466#qq.com(#换@),我们将第一时间删除本站数据。
暂无评论内容