前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Spring Security入门(二) 基于内存存储的表单登录实战

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

作者头像
用户3587585
发布2022-09-21 08:39:03
7170
发布2022-09-21 08:39:03
举报
文章被收录于专栏:阿福谈Web编程

1 Spring Security 实现认证和授权的原理

1.1 过滤器链

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

图片来源:https://docs.spring.io/spring-security/site/docs/5.4.1/reference/html5/#servlet-filters-review

每个Filter的作用在于:

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

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

代码语言:javascript
复制
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 ` 拦截请求的流程图如下:

图片来源:https://docs.spring.io/spring-security/site/docs/5.4.1/reference/html5/#servlet-exceptiontranslationfilter

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

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

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

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

ExceptionTranslationFilter 中的伪代码如下所示:

代码语言:javascript
复制
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实现自定义的认证和授权。

代码语言:javascript
复制
 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起步依赖

代码语言:javascript
复制
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>

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

代码语言:javascript
复制
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类源码如下:

代码语言:javascript
复制
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方法,代码如下:

代码语言:javascript
复制
@RestController
@RequestMapping("/index")
public class IndexController {

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

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

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

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

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

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

欢迎学习 Spring Security!

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

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

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

代码语言:javascript
复制
spring.security.user.name=user
spring.security.user.password=user123
spring.security.user.roles=user

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

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

3.4 自定义继承自继承WebSecurityConfigurerAdapter类的 WebSecurityConfig配置类

代码语言:javascript
复制
@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用户,原始密码为admin
                .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 中添加对应限制角色访问的方法

代码语言:javascript
复制
    @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登录成功后会返回如下响应信息说明登录成功

代码语言:javascript
复制
{"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说明当前用户没有权限访问

代码语言:javascript
复制
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接口

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

本文代码已提交到gitee 个人仓库,地址:https://gitee.com/heshengfu1211/boot-demo.git

感兴趣的小伙伴可以克隆下来参考完整的代码

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

6 参考文章

[1] spring security 官方文档: https://docs.spring.io/spring-security/site/docs/5.4.1/reference/html5/#servlet-applications

[2] 王松著《Spring Boo + Vue 全栈开发实战》第10章Spring Boot 安全管理内容

推荐阅读

[1] Spring Security 入门(一)Spring Security中的认证与密码编码器

[2] SpringBoot之路(一):构建你的第一个Restful Web Service

---END---

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-10-25,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 阿福谈Web编程 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档