Spring Security使用(二) 非同步登入
上一篇文章寫了同步的許可權校驗,今天發一篇非同步請求的
目標
現在做專案大部分都是前後端分離的,不是以前那種垂直的,後端的使用都是呼叫的介面,比如登入,註冊等一些操作,今天的目標就是,用SpringSecurity寫一個使用介面登入以及登入成功後返回登入的資訊,以及當訪問沒有許可權的介面時,返回json,提示它沒許可權操作
專案
JDK 1.8
IDEA 2020
Springboot 2.4.0
一、建立新專案
1.使用Spring Initializr建立
填寫完專案的包名後,在選依賴時,選中這幾個就行了
2.檢查下POM有沒有缺失
上次因為用Spring Initializr,搞的依賴一直不出來,然後中間一直出錯,結果一看pom裡面壓根沒有依賴的座標,所以養成個習慣。。專案建立好後先看pom.xml
3.配置檔案
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.name=defaultDataSource
spring.datasource.url=jdbc:mysql://localhost:3306/liveuser?serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=123456
#Mybatis配置
mybatis.mapper-locations=classpath:/mapper/*.xml
#開啟Mybatis下劃線命名轉駝峰命名
mybatis.configuration.map-underscore-to-camel-case=true
server.port=8080
二、建立資料庫
資料庫用的還是前面那一篇建立的資料庫
/*
Navicat Premium Data Transfer
Source Server : localhost_3306
Source Server Type : MySQL
Source Server Version : 80011
Source Host : localhost:3306
Source Schema : liveuser
Target Server Type : MySQL
Target Server Version : 80011
File Encoding : 65001
Date: 27/11/2020 18:42:16
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for li_role
-- ----------------------------
DROP TABLE IF EXISTS `li_role`;
CREATE TABLE `li_role` (
`id` int(11) NOT NULL,
`name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for li_user
-- ----------------------------
DROP TABLE IF EXISTS `li_user`;
CREATE TABLE `li_user` (
`id` int(11) NOT NULL,
`user` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`pass` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`role` int(255) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
-- 新增許可權
INSERT INTO `liveuser`.`li_role`(`id`, `name`) VALUES (1, 'ROLE_ADMIN')
INSERT INTO `liveuser`.`li_role`(`id`, `name`) VALUES (2, 'ROLE_USER')
-- 新增使用者
INSERT INTO `li_user`(`id`, `user`, `pass`, `role`) VALUES (1, 'admin', '12345', 1)
INSERT INTO `li_user`(`id`, `user`, `pass`, `role`) VALUES (2, 'user', '12345', 2)
三、設計功能以及SQL語句
還是隻有一個登入
SELECT U.ID,U.USER,U.PASS,R.NAME ROLE FROM LI_USER U LEFT JOIN LI_ROLE R ON R.ID = U.ROLE WHERE U.USER = 'username'
四、設計實體類
沒有使用Lombok外掛,直接建立的getter/setter方法,那個tostring在測試的時候列印到控制檯可以看到裡面的屬性值,而不是它的記憶體地址了
package com.example.security2.pojo;
public class User {
Integer id;
String user;
//在轉JSON的時候,忽略Pass這個屬性
@JsonIgnore
String pass;
String role;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getUser() {
return user;
}
public void setUser(String user) {
this.user = user;
}
public String getPass() {
return pass;
}
public void setPass(String pass) {
this.pass = pass;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
@Override
public String toString() {
return "User{" +
"id=" + id +
", user='" + user + '\'' +
", pass='" + pass + '\'' +
", role='" + role + '\'' +
'}';
}
}
五、寫Dao層介面
package com.example.security2.dao;
import com.example.security2.pojo.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface UserDao {
User login(@Param("username") String username);
}
UserMapper.xml
上一篇直接用的select註解,今天用xml的方式來寫,由於把role直接寫實體類裡面了,這裡就不用寫resultMap了,直接使用resultType就行了
<?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.example.security2.dao.UserDao">
<select id="login" resultType="com.example.security2.pojo.User">
SELECT U.ID,U.USER,U.PASS,R.NAME ROLE FROM LI_USER U LEFT JOIN LI_ROLE R ON R.ID = U.ROLE WHERE U.USER = #{username}
</select>
</mapper>
六、業務
ResultVO
package com.example.security2.vo;
import com.fasterxml.jackson.annotation.JsonInclude;
public class ResultVO<E> {
Integer code;
@JsonInclude(JsonInclude.Include.NON_NULL)
String msg;
@JsonInclude(JsonInclude.Include.NON_NULL)
E data;
public ResultVO(){}
public ResultVO(Integer code,String msg,E data){
this.code = code;
this.msg = msg;
this.data = data;
}
}
UserService
package com.example.security2.service;
import com.example.security2.dao.UserDao;
import com.example.security2.pojo.User;
import com.example.security2.vo.ResultVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class UserService {
@Autowired
UserDao userDao;
public ResultVO login(String username, String password){
ResultVO resultVO = new ResultVO();
try{
User user = userDao.login(username);
//迷惑行為,如果密碼錯了提示使用者不存在或密碼錯誤
if(user == null || !user.getPass().equals(password)){
resultVO = new ResultVO(0,"使用者不存在或密碼錯誤",null);
}else{
resultVO = new ResultVO(1,"登入成功",user);
}
}catch (Exception ex){
resultVO = new ResultVO(2,"系統錯誤",ex.getMessage());
}finally {
return resultVO;
}
}
}
七、UserController
package com.example.security2.controller;
import com.example.security2.service.UserService;
import com.example.security2.vo.ResultVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
@Autowired
UserService userService;
@RequestMapping("/login")
public ResultVO Login(String username,String password){
return userService.login(username,password);
}
}
八、測試登入
訪問 localhost:8081/login?username=admin&password=12345
因為我們的賬戶密碼是admin和12345,直接跟著get引數訪問就行了,最後獲取的是我們要拿到的json
從圖片上可以看到,json已經列印到我們的瀏覽器上了,這說明我們的介面一切ok
九、寫登入後操作的介面
接下來隨便寫個介面
@RequestMapping("/test")
public String Test(){
return "test";
}
十、開始配置安全框架
上面是我們不加安全框架的時候的基本操作,接下來我們配置下安全框架
① WebSecurityConfig
package com.example.security2;
import org.springframework.beans.factory.annotation.Configurable;
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.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@Configurable
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
}
public PasswordEncoder getPasswordEncoder(){
return new PasswordEncoder() {
@Override
public String encode(CharSequence charSequence) {
return charSequence.toString();
}
@Override
public boolean matches(CharSequence charSequence, String s) {
return s.equals(charSequence.toString());
}
};
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/js/**").permitAll()
.anyRequest().authenticated()
.and().formLogin()
.loginProcessingUrl("/check") //非同步校驗,這個可以隨便寫路徑,到時候使用ajax的時候,讓它給這裡發請求就行了
.and()
.logout().permitAll();
http.csrf().disable();
}
}
② 建立CustomUserDetailsService
在建立前我們先修改一下UserService,新增一個newLogin的方法,我們用來驗證密碼正確放到新建的CustomUserDetailsService裡面
public User newLogin(String username){
return userDao.login(username);
}
下面就是這個新建的service
package com.example.security2.service;
import com.example.security2.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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 sun.text.normalizer.ICUBinary;
import java.util.ArrayList;
import java.util.Collection;
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
UserService userService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.newLogin(username);
if(user == null){
throw new UsernameNotFoundException("沒有找到這個使用者");
}
Collection<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
authorities.add(new SimpleGrantedAuthority(user.getRole()));
return new org.springframework.security.core.userdetails.User(user.getUser(), user.getPass(), authorities);
}
}
這個返回的user(org.springframework.security.core.userdetails.User)只有使用者名稱,密碼還有它的許可權,如果我們User類中有其他資訊的欄位,怎麼帶過去呢??
我們先新建一個NewUserDetail 讓他繼承UserDetail類
package com.example.security2;
import com.example.security2.vo.ResultVO;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
//讓他繼承 org.springframework.security.core.userdetails.User
public class NewUserDetails extends User {
com.example.security2.pojo.User userInfo; //建立我們的user實體類屬性
public NewUserDetails(com.example.security2.pojo.User user, Collection<? extends GrantedAuthority> authorities) {
super(user.getUser(), user.getPass(), authorities); //這裡讓他去執行它父類的建構函式
this.userInfo = user; //將傳過來的使用者實體類賦值給userinfo屬性
}
public NewUserDetails(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
}
//新建一個獲取ResultVO的類,在這個方法裡面建立我們的VO,並把資料新增進去
public ResultVO getResultVO(){
ResultVO resultVO = new ResultVO();
resultVO.setCode(1);
resultVO.setMsg("登入成功");
resultVO.setData(this.userInfo);
return resultVO;
}
}
接下來修改一下我們的CustomUserDetailsService
package com.example.security2.service;
import com.example.security2.NewUserDetails;
import com.example.security2.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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 sun.text.normalizer.ICUBinary;
import java.util.ArrayList;
import java.util.Collection;
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
UserService userService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.newLogin(username);
if(user == null){
throw new UsernameNotFoundException("沒有找到這個使用者");
}
Collection<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
authorities.add(new SimpleGrantedAuthority(user.getRole()));
return new NewUserDetails(user,authorities); //將這裡的返回建立成我們剛剛新建的一個user
}
}
③ 建立登入成功後的攔截器
為了接收到登入成功的資訊,我們先要實現下 AuthenticationSuccessHandler這個介面
package com.example.security2;
import com.fasterxml.jackson.databind.ObjectMapper;
import jdk.nashorn.internal.parser.JSONParser;
import org.apache.ibatis.reflection.wrapper.ObjectWrapper;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.Writer;
@Component
public class SuccessHandlerImpl implements AuthenticationSuccessHandler {
ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("登入成功");
response.setCharacterEncoding("UTF-8"); //設定輸出資訊的編碼格式
NewUserDetails user= (NewUserDetails) authentication.getPrincipal(); //獲取登入使用者的主體
System.out.println(user.getResultVO()); //這裡列印測試下是不是我們要的資訊
PrintWriter wirter = response.getWriter();
//下面的objectMapper是jackson中轉json的一個物件
if(objectMapper == null){
objectMapper = new ObjectMapper();
}
wirter.println(objectMapper.writeValueAsString(user.getResultVO())); //然他給螢幕上列印我們的json
}
}
④ 建立登入失敗後的攔截器
登入失敗的時候我們應該也是要返回一段json,建立FailHandleImpl類,讓他實現AuthenticationFailureHandler介面
package com.example.security2;
import com.example.security2.vo.ResultVO;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
@Component
public class FailHandleImpl implements AuthenticationFailureHandler {
ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
System.out.println("登入失敗");
response.setCharacterEncoding("UTF-8"); //設定輸出資訊的編碼格式
PrintWriter wirter = response.getWriter();
if(objectMapper == null){
objectMapper = new ObjectMapper();
}
ResultVO resultVO = new ResultVO();
resultVO.setCode(1);
resultVO.setMsg("登入失敗,使用者名稱或密碼錯誤");
wirter.println(objectMapper.writeValueAsString(resultVO));
}
}
⑤ 建立WebSecurityConfig類
安全框架的配置類
package com.example.security2;
import com.example.security2.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Configurable;
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.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@Configurable
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
CustomUserDetailsService customUserDetailsService;
@Autowired
SuccessHandlerImpl successHandler;
@Autowired
FailHandleImpl failHandle;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(customUserDetailsService).passwordEncoder(getPasswordEncoder());
}
public PasswordEncoder getPasswordEncoder(){
return new PasswordEncoder() {
@Override
public String encode(CharSequence charSequence) {
return charSequence.toString();
}
@Override
public boolean matches(CharSequence charSequence, String s) {
return s.equals(charSequence.toString());
}
};
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/js/**").permitAll() //這裡用來釋放我們的js資料夾內的檔案,也就是讓jq或者其他js不用登入通行
.anyRequest().authenticated()
.and().formLogin()
.loginProcessingUrl("/check") //非同步校驗
.successHandler(successHandler) //登入成功的攔截器,使用我們剛剛建立的SuccessHandlerImpl
.failureHandler(failHandle) //登入失敗攔截器,FailHandleImpl
.and()
.logout().permitAll();
http.csrf().disable();
}
}
這個時候,我們的登入介面已經完成了,我們測試一下
看下postman測試列印的內容,成功獲取到我們登入的資訊
這次故意輸錯使用者名稱
十一、新增許可權校驗
① 新增許可權介面的許可權
我們在UserController類中Test方法上加上許可權
@RequestMapping("/test")
@PreAuthorize("hasRole('ROLE_ADMIN')")
public String Test(){
return "test";
}
② 測試
我們先請求登入介面登入admin使用者,然後在請求test看看是不是輸出的test這個字串
接著postman請求登入介面,登入我們的user使用者,然後在用postman請求/test,看下輸出什麼
403我們有沒有辦法讓他返回JSON,提示許可權不足? 往下看
我們建立一個AccessHandleImpl類,讓他實現 AccessDeniedHandler介面
package com.example.security2;
import com.example.security2.vo.ResultVO;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
@Component
public class AccessHandleImpl implements AccessDeniedHandler {
ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
System.out.println("登入失敗");
response.setCharacterEncoding("UTF-8"); //設定輸出資訊的編碼格式
PrintWriter wirter = response.getWriter();
if(objectMapper == null){
objectMapper = new ObjectMapper();
}
ResultVO resultVO = new ResultVO();
resultVO.setCode(2);
resultVO.setMsg("許可權不足");
wirter.println(objectMapper.writeValueAsString(resultVO));
}
}
修改WebSecurityConfig類
http.authorizeRequests()
.antMatchers("/js/**").permitAll()
.anyRequest().authenticated()
.and().formLogin()
.loginProcessingUrl("/check") //非同步校驗
.successHandler(successHandler)
.failureHandler(failHandle)
.permitAll()
.and()
.exceptionHandling().accessDeniedHandler(accessHandle) //這裡加上我們沒有許可權時的攔截器
.and()
.logout().permitAll();
http.csrf().disable();
接下來在測試一下
又遇見個問題,如果我們沒有登入,然後去直接訪問test,則會給我們返回 預設的登入頁面,在postman中會看到這個登入頁面的html程式碼,這個怎麼解決??
我們在UserController類中加一個方法
@RequestMapping("/logintips")
public ResultVO noLogin(){
return new ResultVO(-1,"未登入",null); //為什麼上面沒有用這個構造方法直接用的setter,因為。。在前面用構造方法的時候,msg這裡一直是null,然後沒有找到原因,寫到這裡的時候發現,,我把msg寫成msgm了。。
}
然後在安全框架配置類中新增一個預設的登入頁到這個logintips上就好了
.loginPage("/logintips")
然後在來測試一下看看效果
歡迎訪問:http://www.fanxing.live
相關文章
- Spring Security(二)登入與安全控制Spring
- 二、Spring Security的使用Spring
- [譯] 學習 Spring Security(八):使用 Spring Security OAuth2 實現單點登入SpringOAuth
- Spring Security入門(3-1)Spring Security的登入頁面定製Spring
- Spring Security 之 rememberMe 自動登入SpringREM
- spring security 自定義認證登入Spring
- Spring Security原始碼分析五:Spring Security實現簡訊登入Spring原始碼
- Spring Security OAuth2 單點登入SpringOAuth
- Spring Security 一鍵接入驗證碼登入和小程式登入Spring
- Spring Security(二)Spring
- 實戰開發,使用 Spring Session 與 Spring security 完成網站登入改造!!SpringSession網站
- Spring Security系列之入門應用(二)Spring
- Spring Security - 獲取當前登入使用者的詳細資訊Spring
- 詳解Spring Security的HttpBasic登入驗證模式SpringHTTP模式
- Spring Security系列之實現簡訊登入(十)Spring
- 01-Spring Security框架學習--入門(二)Spring框架
- Spring Cloud Security:Oauth2實現單點登入SpringCloudOAuth
- Spring Security 實戰乾貨:玩轉自定義登入Spring
- Spring Security 整合 微信小程式登入的思路探討Spring微信小程式
- spring security之 預設登入頁原始碼跟蹤Spring原始碼
- Spring Security原始碼分析六:Spring Social社交登入原始碼解析Spring原始碼
- Spring Security原始碼分析三:Spring Social實現QQ社交登入Spring原始碼
- Spring Security原始碼分析十二:Spring Security OAuth2基於JWT實現單點登入Spring原始碼OAuthJWT
- Spring Cloud實戰系列(十) - 單點登入JWT與Spring Security OAuthSpringCloudJWTOAuth
- Spring Security 實戰乾貨:實現自定義退出登入Spring
- spring security 之自定義表單登入原始碼跟蹤Spring原始碼
- Spring Security實現統一登入與許可權控制Spring
- Spring Security原始碼分析二:Spring Security授權過程Spring原始碼
- Spring Security 快速入門Spring
- Spring Security 入門篇Spring
- Spring Security(一)入門Spring
- Spring Security原始碼分析十四:Spring Social社交登入繫結與解綁Spring原始碼
- Spring Security系列之Spring Social社交登入的繫結與解綁(十五)Spring
- Spring Security Oauth2.0 實現簡訊驗證碼登入SpringOAuth
- Spring Security——基於表單登入認證原理及實現Spring
- Spring Security系列教程之實現CAS單點登入上篇-概述Spring
- Spring Security3.0入門Spring
- spring security如何做的user的同步Spring