Spring Security 入门(一) :基于内存存储的表单登录实战

1 Spring Security 实现认证和授权的原理
1.1 过滤器链

Spring SecurityServlet的安全认证是基于包含一系列的过滤器对请求进行层层拦截处理实现的,多个过滤器组成过滤器链。处理单个http请求的过滤链角色示意图如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-otcXhyuJ-1604841699378)(D:\markdown撰写文档\images\filterChain.png)]

每个Filter的作用在于:

  • 阻止处于过滤器链中当前Filter下游Filter和Servlet方法的调用,写响应给客户端的HttpServletResponse

  • 修改用于下游FilterServletHttpServletRequestHttpServletResponse

FilterChain 的使用如下,也就是完成过滤器的doFilter方法中的逻辑

public void doFilter(ServletRequest request, ServletResponse response, FilterChain
chain) {
  // do something before the rest of the application
  chain.doFilter(request, response); // invoke the rest of the application
  // do something after the rest of the application
}

因为每个过滤器只会影响到它下游的FilterServlet,因此每个Filter的执行顺序非常重要。对于每一个请求URL,Spring Security过滤器链中只会执行第一个匹配上的过滤器,后面的过滤器即便匹配上了也不会再执行。

为了对请求进行拦截, Spring Security提供了过滤器 DelegatingFilterProxy类给予开发者配置。

1.2 处理安全异常

Spring Security 提供了一个 ExceptionTranslationFilter 用于处理安全异常。ExceptionTranslationFilter 也是作为一个安全过滤器加入到 FilterChainProxy 中的,它允许将AccessDeniedException(访问拒绝异常)和 AuthenticationException (认证异常) 信息写进 HttpResponse 中。

ExceptionTranslationFilter ` 拦截请求的流程图如下:
异常转换过滤器
图 1 spring security在认证过程发生异常时的过异常转换处理过滤器处理流程

(1) 第一步,ExceptionTranslationFilter执行 FilterChain.doFilter(request, response)方法通过则进入控制器请求方法执行正常逻辑

(2)如果登录用户没有认证或者发送认证异常,则开始认证。此时会发生以下几件事情:

  • SecurityContextHolder 被清除
  • HttpRequest 信息保存在RequestCache中,当用户认证成功则 RequestCache 会响应客户端的原始请求
  • AuthenticationEntryPoint 用来从客户端请求凭据。例如,它会重定向到一个登录页面或者发送一个WWW-Authenticate请求头

(3) 如果发生 AccessDeniedException,代表访问被拒绝,则会执行 AccessDeniedHandler中的方法。

ExceptionTranslationFilter 中的伪代码如下所示:

try {
    //过滤请求    
    filterChain.doFilter(request, response); 
} catch (AccessDeniedException | AuthenticationException ex) {
  if (!authenticated || ex instanceof AuthenticationException) {
      //没有认证或者发送认证异常则开始认证
      startAuthentication(); 
  } else {
     //访问被拒
      accessDenied(); 
  }
}
2 用户/密码认证

认证登录用户最常用的一种方式就是通过验证用户名和密码认证用户。基于此,spring security对使用用户名和密码的方式提供了全面的支持。

2.1读取用户名和密码

spring security提供了以下几种方式从HttpServletRequest中读取用户名和密码:

  • 表单登录
  • Basic 认证
  • 签名认证
2.2 存储认证信息机制

spring security 支持以下几种方式存储用户认证信息,上面每种读取用户名和密码的方式都可以利用下面任何一种存储认证信息的方式实现对访问用户的认证

  • 使用 In-Memory Authentication存储在内存中
  • 使用 JDBC Authentication 认证存储在关系型数据库中
  • 使用 UserDetailsService 存储在自定义数据库中
  • 使用 LDAP Authentication 存储在 LDAP服务器中

限于篇幅,本文只演示基于内存存储的认证方式

2.3 实现自定义认证和授权

spring security提供了一个抽象类WebSecurityConfigurerAdapter实现了默认的认证和授权,我们可以自定义WebSecurityConfig类继承WebSecurityConfigurerAdapter类并重写其中的3个configure实现自定义的认证和授权。

 protected void configure(AuthenticationManagerBuilder auth) throws Exception{......}
     
 public void configure(WebSecurity web) throws Exception {......}   

 protected void configure(HttpSecurity http) throws Exception {......}
3 实现表单登录实战

本文主要利用内存存储和自定义UserDetailsService实现基于内存存储的登录表单认证

3.1 在SpringBoot web项目中加入Spring Security的依赖

在本人之前的boot-demo项目的pom.xml文件中引入spring-boot-starter-security起步依赖

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

而在 Spring Boot 中,只要 加入了Spring security的起步依赖,直接启动spring Boot 的应用也会启用 Spring Security ,这样就可以 看到如下打印随机生成密码的日志(请注意,需要保证你的日志级别为INFO 或者其以下才能看到)

2020-10-19 23:26:37.390  INFO 12808 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: 089ae129-bb01-472f-9919-4bb529b64153
3.2 与用户认证信息有关的自动配置类

开启Spring Security的默认配置就会完成以下事项

  • 创建一个命名为springSecurityFilterChainServlet过滤器 bean ,这个bean负责保护应用的整个安全,包括保护请求的URL、认证提交的用户名和密码和重定向到登录表单等。
  • 创建一个UserDetailsService 类的bean,该类有一个user属性, userusername字段和一个随机生成并打印到控制台上的password字段组成。
  • Servlet容器中注册一个命名为springSecurityFilterChain的过滤器bean 对每一次请求进行过滤。

通过IDEA中搜索UserDetailServiceAutoConfiguration类可进入UserDetailServiceAutoConfiguration配置类的源码。

UserDetailServiceAutoConfiguration配置类的源码中getOrDeducePassword方法会判断代码是否自动生成,如果是则打印生成的密码。然后进入SecurityProperties.User类中查看源码会发现:系统自动生成随机密码是就是一个UUID,而一旦用户配置了密码则passwordGenerated标识符变成了false,使用开发者配置的密码。

SecurityProperties配置类中的静态内部User类源码如下:

public static class User {
        private String name = "user";
        private String password = UUID.randomUUID().toString();
        private List<String> roles = new ArrayList();
        private boolean passwordGenerated = true;

        public User() {
        }

        public String getName() {
            return this.name;
        }

        public void setName(String name) {
            this.name = name;
        }

        public String getPassword() {
            return this.password;
        }

        public void setPassword(String password) {
            if (StringUtils.hasLength(password)) {
                this.passwordGenerated = false;
                this.password = password;
            }
        }

        public List<String> getRoles() {
            return this.roles;
        }

        public void setRoles(List<String> roles) {
            this.roles = new ArrayList(roles);
        }

        public boolean isPasswordGenerated() {
            return this.passwordGenerated;
        }
    }

User类中包含了username、password 和roles 等信息

3.3 使用Spring Security默认的表单登录

在boot-demo 项目com.example.bootdemo.controller包下面新建一个IndexController的控制器,并增加一个index方法,代码如下:

@RestController
@RequestMapping("/index")
public class IndexController {

    @GetMapping("/")
    public String index(){

        return "欢迎学习 Spring Security!";
    }
}

启动项目后在浏览器中输入http://localhost:8088/apiBoot/index/,然后回车。因为用户一开始没有登录认证,所有会被spring security拦截到登录界面让用户先登录。

spring security认证拦截
图 2 spring security 默认的表单登录认证拦截界面

因为我们没有自定义登录界面,所以默认会使用 DefaultLoginPageGeneratingFilter 类,生成上述界面。

默认情况下,Spring Boot UserDetailsServiceAutoConfiguration 自动化配置类,会创建一个内存级别InMemoryUserDetailsManager Bean 对象,提供认证的用户信息。

输入user的用户和应用控制台中打印的登陆密码(32位UUID)登录成功后浏览器页面会出现下面的内容:

欢迎学习 Spring Security!

说明请求进入了IndexControllerindex方法并成功返回。

如果认证失败,则无法跳转到相应的请求方法里去,默认会一直停留在登录界面,但是可以通过配置使路由跳转认证失败的页面。

通常情况下,我们会在application.properties或者application.yaml文件中配置用户名、登录密码和角色等信息,而不是每次拿着一个随机生成的UUID作为密码去登录

spring.security.user.name=user
spring.security.user.password=user123
spring.security.user.roles=user

UserDetailsServiceAutoConfiguration 会基于配置的信息创建一个用户 User 在内存中

此时,我们重启服务器后重新登录输入用户名user和配置的密码user123就能登录成功了

3.4 自定义继承自继承WebSecurityConfigurerAdapter类的WebSecurityConfig配置类
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {


    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth     //使用内存存储
                .inMemoryAuthentication()
                //使用BCrypt密码编码器
                .passwordEncoder( passwordEncoder())
                //配置user用户、密码和角色,此处配置的user用户密码会覆盖系统随机生成的uuID密码
                // 密文在控制台使用springboot-cli指令 spring encodepassword user得到
                .withUser("user").password("$2a$10$bVicNl2vVT0H70APYQYmde9bauRRaENu0HN7HpzByJCtLy0FU0ubu")
                .roles("USER")
                .and()
                //配置admin用户、密码和角色
                .withUser("admin")
                //密文获取方式同user用户
                .password("$2a$10$DHtuK1bibHqbAwoGgLi4zOiNjULuHQ2qhIs/ziCw/9T2fqF320cJu")
                .roles("ADMIN","USER");
    }



    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.authorizeRequests()   
            //使用ant风格拦截请求
            //限制index/user路径对应的接口接口只有USER或ADMIN角色用户可以访问
            .antMatchers("/index/user").hasAnyRole("USER","ADMIN")
               //限制index/admin路径对应的接口只有ADMIN角色用户可以访问
                .antMatchers("/index/admin").hasRole("ADMIN")
                .anyRequest().authenticated()
            //登录接口对所有用户开发权限
                .antMatchers("/login").permitAll()
            //使用spring security默认的登录接口
            //自定义不同路径的认证接口时在登录时报302错误且笔者一时没有找到有效的解决办法
                .and().formLogin().loginProcessingUrl("/login").
                usernameParameter("username").passwordParameter("password")
                //配置登录成功处理器
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication auth) throws IOException, ServletException {
                        //从Authentication实例中拿到当前用户的认证信息
                        Object principal = auth.getPrincipal();
                        //设置响应体内容为json格式
                        response.setContentType("application/json;charset=utf-8");
                        response.setStatus(200);
                        PrintWriter writer = response.getWriter();
                        Map<String,Object> map = new HashMap<>();
                        map.put("status",200);
                        map.put("msg","login success");
                        map.put("data",principal);
                        ObjectMapper objectMapper = new ObjectMapper();
                        //借助ObjectMappe对象将返回数据写到响应体的打印流中
                        //这样就能渲染到客户端浏览器页面,也利于前后端发分离项目
                        //前端跳转页面可以使用vue实现
                        writer.write(objectMapper.writeValueAsString(map));
                        writer.flush();
                        writer.close();
                    }
                }).failureHandler(new AuthenticationFailureHandler() {
            @Override
            public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) throws IOException, ServletException {
                response.setContentType("application/json ;charset=utf-8");
                PrintWriter writer = response.getWriter();
                response.setStatus(401);
                Map<String,Object> map = new HashMap<>();
                map.put("status",401);
                //根据异常类型判断具体的认证失败信息
                if(ex instanceof LockedException){
                    map.put("msg","账号被锁定,登录失败");
                }else if(ex instanceof BadCredentialsException){
                    map.put("msg","账号或密码输入错误,登录失败");
                }else if(ex instanceof DisabledException){
                    map.put("msg","账户被禁用,登录失败");
                }else if(ex instanceof CredentialsExpiredException){
                    map.put("msg","密码过期,登录失败");
                }else{
                    map.put("msg","登录失败");
                }
                ObjectMapper objectMapper = new ObjectMapper();
                writer.write(objectMapper.writeValueAsString(map));
                writer.flush();
                writer.close();
            }
        })      //表单登录对所有用户放开权限
                .permitAll()
                 .and();
    }
    
    //配置BCrypt密码编码器
    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return  new BCryptPasswordEncoder();
    }

}
3.5 IndexController 中添加对应限制角色访问的方法
    @GetMapping("/")
    @GetMapping("/user")
    public String user(){

        return "普通用户或管理员用户能看到我!";
    }

    @GetMapping("/admin")
    public String admin(){

        return "只有管理员用户能看到我!";
    }
4 效果测试

IDEA中启动项目成功后就可以测试效果了

4.1 测试登录接口

在浏览你器中输入 http://localhost:8088/apiBoot/login 然后回车就可以看到和之前一样登录界面

然后在输入框中输入用户名 (user) 和 密码 (user) ,点击 Sign in登录成功后会返回如下响应信息说明登录成功

{"msg":"login success","data":{"password":null,"username":"admin","authorities":[{"authority":"ROLE_ADMIN"},{"authority":"ROLE_USER"}],"accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true},"status":200}

响应体的 data字段中会有用户的信息,包含username、password 和 authorities等字段,其中password字段为null,说明用户认证信息里面没有存储用户的密码,也是为了防止密码泄露。这里要注意Spring Security会给后台配置的用户角色会加上一个ROLE_前缀。

4.2 测试 /index/user 接口和/index/admin接口

(1)使用user用户登录成功后在浏览器中输入 http://localhost:8088/apiBoot/index/user后回车后浏览器中会得到如下响应信息:

普通用户或管理员用户能看到我!

(2) 继续在浏览器中输入 http://localhost:8088/apiBoot/index/admin 后回车,浏览器会得到下面的响应信息,状态码为403说明当前用户没有权限访问

Whitelabel Error Page
This application has no explicit mapping for /error, so you are seeing this as a fallback.

Sun Oct 25 20:57:47 GMT+08:00 2020
There was an unexpected error (type=Forbidden, status=403).
Forbidden

(3) 然后输入http://localhost:8088/apiBoot/login 再次进入登录界面使用admin账户登录,密码为admin, 登录成功后再在浏览器种调用http://localhost:8088/apiBoot/index/admin接口后浏览器种可以看到调用成功的响应信息,说明admin用户能够成功访问index/admin接口

只有管理员用户能看到我!

由于用户的注册信息存在内存中,数据量一旦大起来的话对服务的运行会是一个很大的负担,因此实际的生产环境一般是存储在数据库中的,或者在服务启动成功后开始作为热点数据加载到redis缓存中方便认证用户。 下一篇文章,笔者会尽快推出基于数据库认证的方式实战,敬请期待!

5 参考文章

[1] 《spring-security-reference》 chaper 10 Autherization
[2] 王松著《spring boot2.0 + Vue 全栈开发实战》

文章首发个人微信公众号,第一次阅读笔者文章的小伙伴欢迎扫描下方二维码关注笔者的个人微信公众号,作者会不定期分析前后端技术干货以及一些已拿到大厂offer的同行面试题和求职经验。

个人公众号二维码

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

heshengfu1211

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值