前后端分离下的表单登录
环境搭建
1. 创建User类
package com.sy.security.domain.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.io.Serializable;
import java.util.Collection;
import java.util.List;
/**
* @author 沈洋 邮箱:1845973183@qq.com
* @create 2021/9/7-20:45
**/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable, UserDetails {
private String username;
private String password;
private String realName;
private String roles;
private List<GrantedAuthority> authorities;
public User(String username, String password, String realName, String roles) {
this.username = username;
this.password = password;
this.realName = realName;
this.roles = roles;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
public void setAuthorities(List<GrantedAuthority> authorities) {
this.authorities = authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
2. 创建UserService(根据用户名去查询用户信息)
package com.sy.security.service;
import com.sy.security.domain.pojo.User;
import java.util.HashMap;
/**
* @author 沈洋 邮箱:1845973183@qq.com
* @create 2021/9/7-20:44
**/
public class UserService {
private static final HashMap<String,User> map;
//模拟数据库
static {
map = new HashMap<>();
map.put("2019110231",new User("2019110231","123456","沈洋"));
map.put("2019110211",new User("2019110211","654321","韩世凯"));
}
//模拟查询数据库
public User selectUserByUsername(String username){
return map.get(username);
}
}
3. 前后端分离下,后端接口统一返回类
package com.sy.security.domain.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import org.springframework.http.HttpStatus;
import java.io.Serializable;
import java.util.HashMap;
/**
* @author 沈洋 邮箱:1845973183@qq.com
* @create 31-05-2021-18:05
**/
public class Result implements Serializable {
/**
* 默认成功响应码
*/
private static final Integer DEFAULT_SUCCESS_CODE = HttpStatus.OK.value();
/**
* 默认成功响应信息
*/
private static final String DEFAULT_SUCCESS_MSG = "请求/处理成功!";
/**
* 默认失败响应码
*/
private static final Integer DEFAULT_FAILURE_CODE = HttpStatus.INTERNAL_SERVER_ERROR.value();
/**
* 默认失败响应信息
*/
private static final String DEFAULT_FAILURE_MSG = "请求/处理失败!";
@Getter
private Meta meta;
@Getter
private HashMap<String,Object> data;
public static Result success(){
Result result = new Result();
result.meta = new Meta(DEFAULT_SUCCESS_CODE, DEFAULT_SUCCESS_MSG);
return result;
}
public static Result success(String msg){
Result result = new Result();
result.meta = new Meta(DEFAULT_SUCCESS_CODE, msg);
return result;
}
public static Result success(HttpStatus status,String msg){
Result result = new Result();
result.meta = new Meta(status.value(), msg);
return result;
}
public static Result failure(){
Result result = new Result();
result.meta = new Meta(DEFAULT_FAILURE_CODE, DEFAULT_FAILURE_MSG);
return result;
}
public static Result failure(String msg){
Result result = new Result();
result.meta = new Meta(DEFAULT_FAILURE_CODE, msg);
return result;
}
public static Result failure(HttpStatus httpStatus, String msg){
Result result = new Result();
result.meta = new Meta(httpStatus.value(), msg);
return result;
}
public Result build(){
this.data=new HashMap<>();
return this;
}
public Result build(HashMap<String,Object> map){
this.data=map;
return this;
}
public Result data(String name,Object o){
if(this.data==null) data=new HashMap<>();
this.data.put(name,o);
return this;
}
@Data
@AllArgsConstructor
private static class Meta {
/**
* 处理结果代码,与 HTTP 状态响应码对应
*/
private Integer code;
/**
* 处理结果信息
*/
private String msg;
}
}
具体实现
1. 创建WebSecurityConfig配置类
新建config包,创建我们自己的WebSecurityConfig类并且继承Security框架的WebSecurityConfigAdapter类,添加注解@EnableWebSecurity注解(该注解是有@Configuration注解的,也就是说开启后我们自定义的配置将会被Security发现并注册)
package com.sy.security.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import javax.sql.DataSource;
/**
* @author 沈洋 邮箱:1845973183@qq.com
* @create 26-06-2021-19:37
**/
@Slf4j
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
//这里面的接口会开放访问权限
.antMatchers("/hello/test").permitAll()
.anyRequest().authenticated()
//关闭跨域
.and()
.csrf().disable()
.formLogin()
//未认证的用户被拦截后处理的地方(前后端分离的情况下路径是一个后端处理接口)security默认实现是
.loginPage("/authentication/require")
//处理登陆请求的接口(也就是Security要处理登录逻辑的接口)
.loginProcessingUrl("/login")
//登陆成功处理器
//.successHandler(authenticationSuccessHandler)
//登陆失败处理器
//.failureHandler(authenticationFailureHandler)
//登录请求放行
.permitAll()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
//随机盐被直接放到加密后的密码串中,验证时会自动取出里面的盐来进行匹配
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
登录逻辑
-
Security根据我们配置的拦截路径(/login)对所有请求进行拦截
-
当遇到是登录请求时,会将请求拦截下来交给AuthenticationManager
-
manager负责管理所有能进行认证的provider,当需要认证时manager会遍历所有provider并选择合适的provider进行认证
-
provider对登录进行校验,并调用UserDetailsService的实现类去查找用户,service找到user后(根据自己逻辑实现)返回给provider
- 由provider进行密码校验(使用BCryptPasswordEncoder验证密码)
- 验证成功后封装成Authentication对象返回给上层manager即认证成功,并且调用SuccessHandler进行处理(根据自己逻辑实现)
- 验证失败时调用FailureHandler进行处理(根据自己逻辑实现)
Tips:
-
我们如果要实现自己的其他登录方式比如手机验证码登录、邮箱验证码登录等就需要实现自己的provider以及service等。
因为默认的UsernamePasswordAuthenticationFilter是有进行认证的Provider的,所以这里我们就只需要实现自己的UserDetailsService、SuccessHandler、FailureHandler就可以了
-
这里还需要注意一点的是在使用用户名密码登录的情况下,Security默认是获取post请求,请求体中的参数,所以默认情况下前端在发送登录请求时不能使用json来传递登录信息(后端也可以修改回去参数的形式,这里就不介绍了)
2. 实现自己的UserDetailService
package com.sy.security.service;
import com.sy.security.domain.pojo.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
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;
/**
* @author 沈洋 邮箱:1845973183@qq.com
**/
@Service
@Slf4j
public class MyUserDetailService implements UserDetailsService {
//注入登陆的service
@Autowired
private UserService userService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据传入的用户名查询
log.info("传入的用户名"+username);
UserDetails user = buildUser(username);
if(user==null) throw new UsernameNotFoundException("用户不存在");
return user;
}
/**
* 模拟获取登录用户
* @return 返回用户实例
*/
private UserDetails buildUser(String username){
User user = null;
user = userService.selectUserByUsername(username);
if(user!=null){
// log.info("从数据库中获取到的用户信息"+user);
user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));
return user;
}
return null;
}
}
3. 登陆成功处理
package com.sy.security.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sy.security.domain.pojo.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
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.util.Map;
/**
* @author 沈洋 邮箱:1845973183@qq.com
* @create 2021/5/13-15:21
**/
@Component
@Slf4j
public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
//登录成功后调用
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
SecurityContextHolder.getContext().setAuthentication(authentication);
//根据配置中来选择是执行跳转,还是返回字符串
response.setContentType("application/json;charset=UTF-8");
//登陆成功,返回认证信息
User user = (User) authentication.getPrincipal();
response.getWriter().write(objectMapper.writeValueAsString(user));
}
}
4. 登陆失败处理
package com.sy.security.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sy.security.domain.pojo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* @author 沈洋 邮箱:1845973183@qq.com
* @create 2021/5/13-15:34
**/
@Component
@Slf4j
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Autowired
private ObjectMapper objectMapper;
//登录失败后调用
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(Result.failure(exception.getMessage())));
}
}
注册
将成功/失败处理器注册到Security中并重启
测试
- 访问hello,被拦截
- 调用登录接口,传入正确的用户名和密码,登录成功
- 继续调用hello接口,正常访问
认证后如何获取到用户信息
security用户登录后默认使用session将用户信息保存起来,后续请求进来时根据sessionid将用户信息取出来放入SecurityContextHolder中,这也是为什么我们可以通过SecurityContextHolder能在各个接口中获取到用户信息的原因。
-
SecurityContextHolder.getContext().getAuthentication();
点进SecurityContextHolder的源码可以看到真正保存用户信息的strategy其内部是使用ThreadLocalMap来保存的,而ThreadLocalMap本身只属于当前线程,一次请求结束后会被清除。
security用户登录后默认使用session将用户信息保存起来,后续请求进来时根据sessionid将用户信息取出来放入SecurityContextHolder中,这也是为什么我们可以通过SecurityContextHolder能在各个接口中获取到用户信息的原因。
@GetMapping("/user/me")
public Object getCurrentUser(){
return SecurityContextHolder.getContext().getAuthentication();
}
- 直接在方法上加入Authentication
@GetMapping("/user/me")
public Object getCurrentUser(Authentication authentication){
return authentication;
}
前端SessionId变化问题
**问题:**因为Security默认的策略是将用户信息存放在session中,一个用户登录后会为其分配sessionid存放在浏览器的cookie中。但经常会遇到前端发送的请求每次sessionid变化的问题。对于前后端分离的项目来说前端sessionid一直变化后端就会认为是不同的用户发起的请求,无法根据sessionid获取到存放在session中的信息。从而认证失败
解决:
- 一种解决办法是前端每次发送请求时将请求头中的sessionid设置为上一次的sessionid来保持sessionid不变化
- 使用基于JWT的验证方式
- List item