目录

一、引入依赖

二、编写核心配置类

1、配置类注解,以及继承父类

2、在配置类中注入所需属性

3、配置密码加密所需的bean

4、编写核心配置

5、配置登录所需的service类,以及实现加密的对象

6、解决跨域问题,并在上面的核心配置方法中配置

7、对并发session进行管理

三、定义一个保存用户信息的类,需要继承 UserDetails 接口

四、定义一个类实现UserDetailsService接口

五、定义一个类继承UsernamePasswordAuthenticationFilter

1、定义实现类,并继承接口

2、定义相关属性

3、通过构造方法来接收传入属性值

4、重写attemptAuthentication()方法,用于获取请求的json数据,实现登录功能

5、重写登录成功方法

6、重写登录失败方法

7、响应工具类

六、重写两个异常类

七、重写用户退出的处理类

八、重写未登录导致未授权的处理方法

九、定义一个类继承ConcurrentSessionFilter

十、定义一个全局异常处理类

十一、补充知识

十二、最终说明

1、Result 类

2、响应状态码

3、token问题

4、本篇文章参考


一、引入依赖

首先引入 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没有用

 对其做出了更加细节化和描述,添加了更多业务需求操作。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐