1. 导入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

2. 编写用户认证服务类UserDetailsServiceImpl

用户认证服务类需要复写UserDetailsService中的UserDetails -> loadUserByUsername(String username)方法

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    private final MageService service; // 用户信息CRUD服务类

    public UserDetailsServiceImpl(MageService service) {
        this.service = service;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Mage usr = service.findByName(username); // 根据username查询用户
        if (usr != null) {
            return new User(usr.getMageName(), usr.getMagePassword(), AuthorityUtils.NO_AUTHORITIES);
        } else {
            throw new UsernameNotFoundException("当前用户不存在!");
        }
    }
}

loadUserByUsername(String username) 方法返回的类型是UserDetails,创建UserDetails类需要传3个参数

分别是username,password,Collection<? extends GrantedAuthority> authorities

authorities是用来设置用户权限访问的,这个参数即使表中没有也不能不传,如果需求不需要设置权限,可以传AuthorityUtils.NO_AUTHORITIES,表示没权限设置

3. 编写Spring Security配置类SecurityConfig

1. Spring Security的配置类需要继承WebSecurityConfigurerAdapter

@Configuration // 声明配置类
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

2. 设置安全认证的加密方式,早期的Spring Security不是强制性的

/**
* 设置加密方式
*/
@Bean
PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

3. 用户认证方式

/**
* 用户认证
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService);
}

4. 安全认证的拦截和回调

前后端不分离,安全认证要做的事是拦截请求,根据配置的情况,跳转到指定页面

前后端分离,安全认证要做的事是拦截请求,根据配置的情况,返回json格式的提示信息给前端,让前端做跳转,后端就不再需要写跳转的代码了

1. 登录成功后

前后端不分离,使用.successForwardUrl() 或者.defaultSuccessUrl() 实现登录成功后的页面跳转

前后端分离,使用.successHandler()实现登录成功后向前端发送json格式的回馈信息

.formLogin()
    .loginProcessingUrl("/doLogin")
    .permitAll()
    .successHandler(new AuthenticationSuccessHandler() {
        @Override
        public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
            Map<String, Object> map = new HashMap<>();
            map.put("msg", "登录成功!");
            map.put("principal", authentication.getPrincipal());
            resp.setContentType("application/json:charset=utf-8");
            PrintWriter out = resp.getWriter();
            // 对象转json传输给前端
            out.write(new ObjectMapper().writeValueAsString(map));
            out.flush();
            out.close();
        }
    })

这里需要重点讲讲 .loginPage().loginProcessingUrl() 这两者的区别

.loginPage(/xxx) : 在没有登录的情况下,用户访问需要登录才能访问的页面时,会自动跳转到 /xxx这个地址,这个地址可以是指定一个xxx.html页面,也可以是一个跳转页面的接口

.loginProcessingUrl() :前端发送登录表单的目标地址,也就是 action = ""中的值

对于前后端分离的项目,后端是不需要处理跳转问题的,所以一般也不会用到.loginPage(/xxx)

authentication.getPrincipal() :获取的是登录用户的基本信息

2. 登录失败后

前后端不分离,使用.failureForwardUrl() 或者.failureUrl() 实现登录失败后的页面跳转

前后端分离,使用.failureHandler()实现登录失败后向前端发送json格式的回馈信息

.failureHandler(new AuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest req, HttpServletResponse resp, AuthenticationException e) throws IOException, ServletException {
                        Map<String, Object> map = new HashMap<>();
                        map.put("msg", "登录失败!");
                        resp.setContentType("application/json:charset=utf-8");
                        PrintWriter out = resp.getWriter();
                        // 对象转json传输给前端
                        out.write(new ObjectMapper().writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                })

3. 注销后

.logout()
                .logoutUrl("/logout")
                .permitAll()
                .logoutSuccessHandler(new LogoutSuccessHandler() {
                    @Override
                    public void onLogoutSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
                        Map<String, Object> map = new HashMap<>();
                        map.put("state", 200);
                        map.put("msg", "注销成功!");
                        resp.setContentType("application/json;charset=utf-8");
                        PrintWriter out = resp.getWriter();
                        // 对象转json传输给前端
                        out.write(new ObjectMapper().writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                })

4. 访问拦截

如果用户访问需要登录才能访问的页面,后端将会返回提示信息给前端

 .exceptionHandling()// 异常抛出
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    @Override
                    public void commence(HttpServletRequest req, HttpServletResponse resp, AuthenticationException e) throws IOException, ServletException {
                        Map<String, Object> map = new HashMap<>();
                        map.put("state", 403);
                        map.put("msg", "没有访问权限,请先登录!");
                        resp.setContentType("application/json;charset=utf-8");
                        PrintWriter out = resp.getWriter();
                        // 对象转json传输给前端
                        out.write(new ObjectMapper().writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                })

到这里,前后端分离的Spring Security的配置就完成了,但是上面的方式只支持前端以表单的形式发送登录请求,如果想用json格式发送登录请求,上面的配置是不支持的,下面提供的是既支持表单也支持json格式的请求

4. 配置支持json格式的登录请求

我们先来看看,Spring Security默认支持表单请求登录的源码

类名:UsernamePasswordAuthenticationFilter

方法:

public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            // 以Key-value的形式获取用户名和密码
            String username = this.obtainUsername(request);
            String password = this.obtainPassword(request);
            if (username == null) {
                username = "";
            }

            if (password == null) {
                password = "";
            }

            username = username.trim();
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

我们可以看到,Spring Security是以key-value的形式获取用户名和密码的,也就是表单的形式,如果前端传输的是json的格式,Spring Security是获取不到用户名和密码的,所以我们只需要修改Spring Security获取用户名和密码的方式就可以了。

1. 编写LoginFilter

public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 不是post请求抛出异常
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        // 判断请求是否是json格式,如果不是直接调用父类
        if (request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)) {
            // 把request的json数据转换为Map
            Map<String, String> loginData = new HashMap<>();
            try {
                loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class);
            } catch (IOException e) {
                e.printStackTrace();
            }
            // 调用父类的getParameter() 方法获取key值
            String username = loginData.get(this.getUsernameParameter());
            String password = loginData.get(this.getPasswordParameter());
            if (username == null) {
                username = "";
            }
            if (password == null) {
                password = "";
            }
            username = username.trim();
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        } else {
            return super.attemptAuthentication(request, response);
        }
    }
}

根据请求的类型(表单还是json)去执行相应获取用户名和密码的代码,如果是表单,我们直接调用父类方法super.attemptAuthentication(request, response); 就可以了,否则获取json数据中的用户名和密码

2. 在Spring Security配置类中注入LoginFilter

/**
 * 自定义 UsernamePasswordAuthenticationFilter 过滤器
 */
@Bean
LoginFilter loginFilter() throws Exception {
    LoginFilter loginFilter = new LoginFilter();
    // 前端的登录请求地址
    loginFilter.setFilterProcessesUrl("/doLogin");
    // 登录成功后返回给前端的json数据
    loginFilter.setAuthenticationSuccessHandler(new AuthenticationSuccessHandler() {
        @Override
        public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
            Map<String, Object> map = new HashMap<>();
            map.put("state", 200);
            map.put("msg", "登录成功!");
            map.put("principal", authentication.getPrincipal());
            resp.setContentType("application/json;charset=utf-8");
            PrintWriter out = resp.getWriter();
            // 对象转json传输给前端
            out.write(new ObjectMapper().writeValueAsString(map));
            out.flush();
            out.close();
        }
    });
    // 登录失败后返回给前端的json数据
    loginFilter.setAuthenticationFailureHandler(new AuthenticationFailureHandler() {
        @Override
        public void onAuthenticationFailure(HttpServletRequest req, HttpServletResponse resp, AuthenticationException e) throws IOException, ServletException {
            Map<String, Object> map = new HashMap<>();
            map.put("state", 403);
            map.put("msg", "登录失败!");
            resp.setContentType("application/json;charset=utf-8");
            PrintWriter out = resp.getWriter();
            // 对象转json传输给前端
            out.write(new ObjectMapper().writeValueAsString(map));
            out.flush();
            out.close();
        }
    });
    loginFilter.setAuthenticationManager(authenticationManagerBean());
    return loginFilter;
}

登录成功和登录失败的反馈信息在LoginFilter实现过后,前面在.formLogin()中实现的登录成功和登录失败的代码就不需要了,可以删除。

3. 在configure(HttpSecurity http)中添加LoginFilter过滤器

// 把默认的UsernamePasswordAuthenticationFilter 过滤器替换成自定义过滤器loginFilter
http.addFilterAfter(loginFilter(), UsernamePasswordAuthenticationFilter.class);