目录
三、定义一个保存用户信息的类,需要继承 UserDetails 接口
五、定义一个类继承UsernamePasswordAuthenticationFilter
4、重写attemptAuthentication()方法,用于获取请求的json数据,实现登录功能
九、定义一个类继承ConcurrentSessionFilter
一、引入依赖
首先引入 spring security 启动器的依赖
<!-- spring security 启动器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
二、编写核心配置类
1、配置类注解,以及继承父类
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecuConfig extends WebSecurityConfigurerAdapter{
}
2、在配置类中注入所需属性
将业务所需属性全部注入,除 redis 其他都是必须注入(我这里需要用到缓存),loginService 是自己登录实体类的service接口,根据自己项目来命名
如果 redis 不知道怎么配置,可以查看 => 整合redis并配置mybatis的二级缓存
// 在配置类中注入所需属性,传入Bean对象
@Autowired
private UserDetailsService userDetailsService;
@Autowired // redis
private StringRedisTemplate redisTemplate;
@Autowired
private UserDetailsServiceImpl userDetailsServiceImpl;
@Autowired // 用户实体类的service
private LoginService loginService;
private SessionRegistry sessionRegistry = new SessionRegistryImpl();
3、配置密码加密所需的bean
我这里没有打开密码加密,需要的话解开这个注释即可 //return new BCryptPasswordEncoder();
// 配置密码加密所需的bean
@Bean
public PasswordEncoder getPasswordEncoder(){
//return new BCryptPasswordEncoder();
return NoOpPasswordEncoder.getInstance();
}
4、编写核心配置
其中配置了未登录导致授权失败的处理类、登出需要访问的路径、登出的处理类、以及一些过滤器、跨域的相关处理等。
我原先在这里配置了sessionManagement().maximumSessions(1),也就是不允许一个账号同时在多个地方登录,一个账号只能拥有一个session对象,但是配置了以后始终不生效,所以在后面的其他类里实现了
我这里没有加入登出相关类,因为在前端实现了,并不需要后端登出
放行的请求一般都是登录有关的请求,因为此时还没有session,如果不放行将无法访问
// 编写核心配置
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling()
.authenticationEntryPoint(new UnauthorizedEntryPoint())
.and().csrf().disable() //关闭csrf
.authorizeRequests().antMatchers(
"/basic-api/**",
"/sys/basic-api/registerDept/tree").permitAll() //放行的请求路径
.anyRequest().authenticated()
//.and().logout(logout -> logout.deleteCookies("JSESSIONID")).logout().logoutUrl("/sys/logout")
//.addLogoutHandler(new TokenLogoutHandler())
.and()
//传入bean对象给TokenLoginFilter
.addFilter(new TokenLoginFilter(authenticationManager(),sessionRegistry,loginService,redisTemplate))
.addFilter(new MyConcurrentSessionFilter(sessionRegistry))
.cors().configurationSource(corsConfigurationSource());
}
5、配置登录所需的service类,以及实现加密的对象
这里的service类需要后面编写自定义登录类来引入
// 配置登录所需的service类,以及实现加密的对象
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsServiceImpl).passwordEncoder(getPasswordEncoder());
}
6、解决跨域问题,并在上面的核心配置方法中配置
// 解决跨域问题,并在上面的核心配置方法中配置
private CorsConfigurationSource corsConfigurationSource() {
CorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin("*"); //同源配置,*表示任何请求都视为同源,若需指定ip和端口可以改为如“localhost:8080”,多个以“,”分隔;
corsConfiguration.addAllowedHeader("*");//header,允许哪些header,本案中使用的是token,此处可将*替换为token;
corsConfiguration.addAllowedMethod("*"); //允许的请求方法,PSOT、GET等
((UrlBasedCorsConfigurationSource) source).registerCorsConfiguration("/**",corsConfiguration); //配置允许跨域访问的url
return source;
}
7、对并发session进行管理
// 对并发session进行管理
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
三、定义一个保存用户信息的类,需要继承 UserDetails 接口
直接传入自己登录用户的实体类,然后修改 getPassword()和 getUsername()两个方法返回的值
其他方法可以暂时全部默认返回为空,具体的是用户是否启用、是否有效等等判断,可以自行编辑逻辑,我就统一在自定义登陆类里做判断了
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SecurityUser implements UserDetails {
// 传入自己的登录用户实体类
private LoginOper loginOper;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return loginOper.getPassword();
}
@Override
public String getUsername() {
return loginOper.getOperName();
}
// 暂时不经过判断,直接默认为true
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
四、定义一个类实现UserDetailsService接口
这里是整个 security 获取数据 => 查询信息验证 => 登录成功 => 登录失败 流程的第二步
重写loadUserByUsername()方法,用于查询用户信息,并保存到SecurityUser里面,进行返回
返回之后 security会自动根据查询出来的密码和表单输入的密码来判断是否登录成功
成功则直接调用成功方法,失败则调用失败方法,无需自己判断密码
以下其他部分是我自己编写的业务逻辑,根据传入的用户名称查询用户信息,然后判断用户状态,密码状态等等,抛出相应的异常(用重写登陆失败方法来接收判断返回给前端)
这个类就是需要引入到核心配置类当中的
@Service("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private LoginService loginService;
/***
* 根据用户名获取用户信息
* @param operName
* @return: org.springframework.security.core.userdetails.UserDetails
*/
// 第二步:验证用户名状态
@SneakyThrows
@Override
public UserDetails loadUserByUsername(String operName) {
// 从数据库中取出用户信息
LoginOper loginOper = loginService.getOne(new QueryWrapper<LoginOper>().eq("oper_name", operName));
SimpleDateFormat formatter = new SimpleDateFormat("dd-MM-yyyy");
String date = formatter.format(new Date());
// 判断用户是否存在
if (loginOper == null){
throw new UsernameNotFoundException("用户名不存在,请重新输入!");
}
// 判断用户状态是否已停用
else if("0".equals(loginOper.getOperState())) {
throw new UserCountLockException("该用户账号已停用");
}
// 判断用户密码状态是否正常,0为失效,1为正常,2为过期,3为冻结,4为初始化
else if(!"1".equals(loginOper.getPasswordState())) {
if("0".equals(loginOper.getPasswordState())){
throw new UserCountLockException("该用户密码已失效,请联系管理员");
}else if ("2".equals(loginOper.getPasswordState())){
throw new UserCountLockException("该用户密码已过期,请修改后再登陆");
}else if ("3".equals(loginOper.getPasswordState())){
throw new UserCountLockException("该用户密码已冻结,请联系管理员");
}else if ("4".equals(loginOper.getPasswordState())){
throw new UserCountLockException("该用户密码已初始化,请修改后再登录");
}
}
// 判断用户当日密码错误次数是否大于5次
else if(loginOper.getPasswordWrongnum() >= 5) {
// 已冻结
if(!"3".equals(loginOper.getPasswordState())){
loginOper.setPasswordState("3");
loginService.updateById(loginOper);
}
throw new UserCountLockException("当日密码错误次数大于5次,账户已冻结");
}
// 判断用户是否过期
else if(formatter.parse(formatter.format(loginOper.getPasswordExpireDate())).before(formatter.parse(date))) {
// 已过期,将密码状态修改为2
if(!"2".equals(loginOper.getPasswordState())){
loginOper.setPasswordState("2");
loginService.updateById(loginOper);
}
throw new UserCountLockException("该用户密码已过期,请修改后再登陆");
}
// 返回UserDetails实现类
SecurityUser securityUser = new SecurityUser(loginOper);
return securityUser;
}
}
五、定义一个类继承UsernamePasswordAuthenticationFilter
由于当前项目使用json格式获取用户登录的手机号和密码,但是springsecurity默认不支持json格式登录,所以只能自己去重写过滤器,定义一个类继承UsernamePasswordAuthenticationFilter,也用于配置登录相关信息,解决maximumSessions(1)不生效的问题
1、定义实现类,并继承接口
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter
2、定义相关属性
// 用于接收传进来的bean对象
private StringRedisTemplate redisTemplate;
private AuthenticationManager authenticationManager;
private LoginService loginService;
private SessionRegistry sessionRegistry;
3、通过构造方法来接收传入属性值
接收传过来的Bean对象,指定登录接口路径和请求方式,并且调用了并发session的api,用于解决在核心配置类当中maximumSessions(1)不生效的问题,因为重写过滤器会覆盖springsecurity原有的逻辑,并发session只能自己调用方法去实现。
public TokenLoginFilter(AuthenticationManager authenticationManager,SessionRegistry sessionRegistry,LoginService loginService,StringRedisTemplate redisTemplate) {
this.authenticationManager = authenticationManager;
this.sessionRegistry = sessionRegistry;
this.loginService = loginService;
this.redisTemplate = redisTemplate;
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/basic-api/_user/login","POST"));
//设置一个账号只能拥有一个session对象
ConcurrentSessionControlAuthenticationStrategy sessionStrategy=new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry);
sessionStrategy.setMaximumSessions(1);
this.setSessionAuthenticationStrategy(sessionStrategy);
}
4、重写attemptAuthentication()方法,用于获取请求的json数据,实现登录功能
这里是整个 security 获取数据 => 查询信息验证 => 登录成功 => 登录失败 流程的第一步
security会在请求拦截器之前执行
// 第一步:获取表单信息,security会在请求拦截器之前
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res)
throws AuthenticationException {
//获取表单提交的数据
Map map = new ObjectMapper().readValue(req.getInputStream(), Map.class);
String operName = (String) map.get("operName");
String password = (String) map.get("password");
// 将用户名存入session域中
req.getSession().setAttribute("operName", operName);
// 最终交给security校验密码
System.out.println("账号为:"+operName);
return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(operName, password));
}
5、重写登录成功方法
这里是整个 security 获取数据 => 查询信息验证 => 登录成功 => 登录失败 流程的第三步
这里面主要编写 密码验证成功(登录成功)后的一些逻辑,例如下面我自己写的一些业务逻辑
登录成功后,获取securityUser对象,然后根据其中的用户id和用户等级查询数据库,得到用户的权限,再保存当前已认证的用户信息。其中,这行代码很重要
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(securityUser,securityUser.getId(),authorities));
用于保存已认证的用户信息,并自动加入session缓存,springsecurity就是根据这里面的信息获取当前用户的权限,也可以在其他地方调用SecurityContextHolder类,来获取已登录的用户信息。
// 第三步:重写登录成功方法:也可以在此做业务判断,将用户信息返回到前端,再做判断,但是不安全
@SneakyThrows
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
// 判断得到的用户信息是否为空,如果为空说明用户名不存在
if(authResult.getPrincipal() == null){
ResponseUtil.out(response,null);
}else {
SecurityUser securityUser = (SecurityUser) authResult.getPrincipal();
// 这里需要在登录用户实体类中加入一个构造方法
LoginOper loginOper = new LoginOper(securityUser.getLoginOper());
// 用户权限,暂时设定为空
List<GrantedAuthority> authorities = null;
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(securityUser,securityUser.getLoginOper().getOperNo(),authorities));
sessionRegistry.registerNewSession(request.getSession().getId(),securityUser);
// 登录成功,将密错误次数清零
loginOper.setPasswordWrongnum(0);
// 查询该用户所拥有的角色id,并插入
List<Integer> roleIdHaveList = loginService.getRoleIdHaveList(loginOper.getOperNo());
loginOper.setRoleList(roleIdHaveList);
ResponseUtil.out(response,loginOper);
// 登录成功,记录本次成功时间和成功状态,必须在最后set,否则会将本次登录时间状态信息返回给前端,前端应展示上次登录时间状态信息
loginOper.setLastLoginDate(new Date());
loginOper.setLastLoginState("1");
loginService.updateById(loginOper);
}
}
注意在new LoginOper(securityUser.getLoginOper());的时候,需要在这个实体类中自己写一个构造方法,具体代码如下
public LoginOper(LoginOper loginOper) {
this.operNo = loginOper.getOperNo();
this.operName = loginOper.getOperName();
......
}
6、重写登录失败方法
这里是整个 security 获取数据 => 查询信息验证 => 登录成功 => 登录失败 流程的第四步
这里面主要编写 密码验证失败、用户名不存在等等(登录失败)后的一些逻辑,例如下面我自己写的一些业务逻辑
由于我在自定义登录验证信息类中加了其他逻辑,抛出了很多其他不同信息的异常,在这里都可以做一个判断,判断具体是哪种异常,然后反馈给前端。
如果是默认“Bad credentials”信息,而不是我自己抛出的异常信息,那就只有一种可能,是密码错误。如果不抛出不同的错误信息,那“Bad credentials”就有很多种可能,从而无法判断具体是什么原因导致登陆失败(用户名不存在、密码错误等等)
// 第四步:重写登录失败方法
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException e) throws IOException, ServletException {
// 从session域中获取用户名
String operName = (String) request.getSession().getAttribute("operName");
LoginOper loginOper = loginService.getOne(new QueryWrapper<LoginOper>().eq("oper_name", operName));
// 登录失败,记录本次失败时间和失败状态
loginOper.setLastLoginDate(new Date());
loginOper.setLastLoginState("0");
if("Bad credentials".equals(e.getMessage())){
int num = 0;
// 密码错误,增加错误次数, 如果修改后大于5,则修改密码状态为3
if(loginOper.getPasswordWrongnum() < 5){
if(loginOper.getPasswordWrongnum() + 1 >= 5){
loginOper.setPasswordState("3");
}
loginOper.setPasswordWrongnum(loginOper.getPasswordWrongnum() + 1);
}else {
loginOper.setPasswordState("3");
}
num = 5 - loginOper.getPasswordWrongnum();
ResponseUtil.out(response,412,new Result(1,"密码错误,"+(num != 0?"密码错误5次将冻结密码,今日还剩"+num+"次":"密码已冻结"),"密码错误,密码错误次数至5次将冻结账户,今日还剩"+num+"次"));
}
// 其他信息验证错误
else {
ResponseUtil.out(response,412,new Result(1,e.getMessage(),e.getMessage()));
}
loginService.updateById(loginOper);
}
用于向前台输出错误信息,这里用到了响应的工具类
7、响应工具类
这里我写了两种构造方法,具体可以根据自己的业务需求来灵活编写
- 自定义状态码,且返回自定义Result类
- 固定返回200状态码,且固定返回 LoginIper 类(登录用户实体类)
public class ResponseUtil {
public static void out(HttpServletResponse response,int state, Result result) {
ObjectMapper mapper = new ObjectMapper();
response.setStatus(state);
response.setContentType("text/json;charset=UTF-8");
try {
mapper.writeValue(response.getWriter(), result);
} catch (IOException e) {
e.printStackTrace();
}
}
public static void out(HttpServletResponse response, LoginOper loginOper) {
ObjectMapper mapper = new ObjectMapper();
response.setStatus(HttpStatus.OK.value()); // 永远返回 200
response.setContentType("text/json;charset=UTF-8");
try {
mapper.writeValue(response.getWriter(), loginOper);
} catch (IOException e) {
e.printStackTrace();
}
}
}
到这里,整个登录过滤器就写完了,添加到核心配置类即可
六、重写两个异常类
自定义重写用户失效异常,和用户不存在异常,用于在用户信息验证方法类中抛出相应异常,然后在登录失败方法中去接收判断,都需要继承 AuthenticationException 接口才可以
/**
* 自定义用户失效异常
*/
public class UserCountLockException extends AuthenticationException {
public UserCountLockException(String msg,Throwable t){
super(msg, t);
}
public UserCountLockException(String msg){
super(msg);
}
}
/**
* 自定义用户不存在异常类
*/
public class UsernameNotFoundException extends AuthenticationException {
public UsernameNotFoundException(String msg, Throwable cause) {
super(msg, cause);
}
public UsernameNotFoundException(String msg) {
super(msg);
}
}
七、重写用户退出的处理类
这里我没有用到这个类,可以根据业务需求自行添加,然后添加到核心配置类当中。
public class TokenLogoutHandler implements LogoutHandler {
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
ResponseUtil.out(response,200, new Result(1,"成功退出","成功退出"));
}
}
八、重写未登录导致未授权的处理方法
重写未登录导致未授权的处理方法,向前台输出未登录的信息。添加到核心配置类当中。
主要用于已经失去权限,或者权限过期却依旧停留在主页面没有退出的情况,再次访问就会提示未登录没有权限。
public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
ResponseUtil.out(response,409,new Result(1,"未登录没有权限","未登录没有权限"));
}
}
九、定义一个类继承ConcurrentSessionFilter
用于解决maximumSessions(1)不生效的问题,并重写同一个账号在多个地方登录后被踢下线的处理逻辑,向前台输出未登录的信息
public class MyConcurrentSessionFilter extends ConcurrentSessionFilter {
public MyConcurrentSessionFilter(SessionRegistry sessionRegistry) {
super(sessionRegistry,event -> {
HttpServletResponse response = event.getResponse();
ResponseUtil.out(response,411,new Result(1,"该账号已在别处登录","该账号已在别处登录"));
});
}
}
十、定义一个全局异常处理类
用于处理用户名不存在、用户没有权限访问的处理逻辑,向前台输出提示信息
@RestControllerAdvice
public class SecurityExceptionHandler {
// 无用
@ExceptionHandler(UsernameNotFoundException.class)
public Result error(HttpServletRequest request, HttpServletResponse response, UsernameNotFoundException e){
response.setStatus(413); // 用户名不存在
e.printStackTrace();
return new Result(1,"用户名不存在",null);
}
// 防止身份过期,session和请求拦截的token依旧没有使用户退出界面或者拦截请求
@ExceptionHandler(AccessDeniedException.class)
public Result error(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e){
response.setStatus(410); // 未登录,身份验证过期失效
e.printStackTrace();
return new Result(1,"未登录","未登录");
}
}
十一、补充知识
springsecurity会在每次访问时,通过过滤器对用户进行授权,如果要在授权时执行一定的逻辑,需要定义一个类,继承BasicAuthenticationFilter,并重写doFilterInternal方法,最后需要保存已认证的用户信息
SecurityContextHolder.getContext().setAuthentication(authentication);
根据情况,判断是否执行下一个过滤链
chain.doFilter(req, res);
本项目中不需要做这些。
十二、最终说明
1、Result 类
本项目用的 Result 类是我自定义的一个接口返回类,专门用于向前端返回数据,具体可以自行灵活定义使用
定义如下:
@Data
@AllArgsConstructor
public class Result {
private Integer code;
private String message;
private Object result;
}
2、响应状态码
代码中我写了很多奇奇怪怪的状态码,是因为想要让前端可以更好的处理特殊自定义状态码,呈现特殊的页面效果反馈给用户,具体可以自行灵活使用
3、token问题
本篇博客中没有涉及到token的原因并非不用,而是没有放在登录验证处理代码中,我的业务需求是登录后还需要进行身份验证(手机短信验证),所以我把token处理放在了身份验证里面,需要的话可以自行在登录成功方法中生成token并添加到redis中
建议加上token,配合请求拦截,这样系统会更加安全
如果不知道怎么配置 token,可以查看文章 => 整合JWT配和请求拦截器进行安全校验
4、项目结构
核心配置类被我放在了config包下
其他部分全都被我放在了common包下
相应工具类
LoginOper 实体类就不展示了
5、本篇文章参考:
SpringSecurity用法详解,解决maximumSessions(1)不生效的问题_woshihedayu的博客-CSDN博客_maximumsessions没有用
对其做出了更加细节化和描述,添加了更多业务需求操作。