iOS Developer的全栈之路 - Keycloak(3)

在将Keycloak集成到SpringBoot之前,需要先了解一下SpringSecurity。

SpringSecurity 是 Spring 项目组中用来提供安全认证服务的框架,它对Web安全性的支持大量地依赖于Servlet过滤器,也就是Spring的DispatcherServlet,这些过滤器拦截请求,并且在应用程序处理该请求之前进行某些安全处理。

启用SpringSecurity

在SpringBoot项目中,启用仅需加入依赖即可:

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

先写一个HelloWorld的Controller:

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
}

启动应用,当没有配置SpringSecurity的依赖时,通过浏览器访问http://localhost:8080/hello会直接显示hello这个字符串,而在加入SpringSecurity的依赖后,页面会自动跳转到http://localhost:8080/login,页面如下图所示:

login.png

此时,我们并没有配置任何用户信息,SpringSecurity为该项目添加了一个默认的用户,用户名为:user,而密码可以在启动的控制台内看到:

...
2019-12-29 21:41:08.214  INFO 74643 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2019-12-29 21:41:08.214  INFO 74643 --- [           main] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 940 ms
2019-12-29 21:41:08.349  INFO 74643 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2019-12-29 21:41:08.508  INFO 74643 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: c8c72970-1213-41da-bd41-cca672655681

2019-12-29 21:41:08.589  INFO 74643 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Creating filter chain: any request, [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@11a7ba62, org.springframework.security.web.context.SecurityContextPersistenceFilter@50825a02, org.springframework.security.web.header.HeaderWriterFilter@4d33940d, org.springframework.security.web.csrf.CsrfFilter@7e8a46b7, org.springframework.security.web.authentication.logout.LogoutFilter@30135202, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@304a3655, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@ff6077, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@340b7ef6, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@7923f5b3, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@703feacd, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@64f555e7, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@30404dba, org.springframework.security.web.session.SessionManagementFilter@37c5fc56, org.springframework.security.web.access.ExceptionTranslationFilter@1ddd3478, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@6ff37443]
2019-12-29 21:41:08.642  INFO 74643 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2019-12-29 21:41:08.645  INFO 74643 --- [           main] c.e.s.SecurityIntegrationApplication     : Started SecurityIntegrationApplication in 1.729 seconds (JVM running for 2.182)
2019-12-29 21:41:19.460  INFO 74643 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2019-12-29 21:41:19.460  INFO 74643 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
...

在输入完用户名密码后,页面会跳转至http://localhost:8080/hello,并显示hello这个字符串。若打开Chrome的调试工具,可以看到如下:

chrome.png

在/hello这个请求中带着这样一段Cookie,这段Cookie就是在login成功后被设置的。

添加用户

接下来,我们为这个应用添加一些默认的用户,添加用户的方式共有三种:

  1. 基于memory db的用户
  2. 在application.properties中配置
  3. 从db中读取

鉴于现在对SpringSecurity的了解是为了之后集成Keycloak,因此此处使用了第一种方式,也是较为简单的方式。为了添加用户,我们需要对SpringSecurity进行配置,需要编译一个继承自WebSecurityConfigurerAdapter的配置类,并为该类添加@Configuration注解:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("user").roles("user")
                .password(passwordEncoder().encode("123"))
                .and()
                .withUser("admin").roles("admin")
                .password(passwordEncoder().encode("123"));
    }
}

实现void configure(AuthenticationManagerBuilder auth)方法,在其中进行配置用户信息,在此配置了两个用户分别为user和admin。而上方的那个@Bean是用来给密码加密/加盐的。

在添加了这个配置类后,重启应用,此时在控制台中就不再有默认的密码信息了,再次访问/hello,就可以通过刚刚配置的两个用户进行登录了。

忽略某些endpoint

在我们没有进行任何配置的情况下,SpringSecurity将保护所有的endpoint,通常情况下,我们是需要暴露某些endpoint的,此时就需要实现配置类的另一个方法:

...
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/yo");
    }
...

看方法名即可看出,将忽略掉/yo这个endpoint。

根据role来匹配可以访问的endpoint

void configure(AuthenticationManagerBuilder auth)配置中,我们配置了两个用户,并且分别给与了两个不同的身份,若想根据身份的不同来限制访问,就需要实现另一个方法了:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin").hasRole("admin")
                .antMatchers("/hello").hasRole("user")
                .anyRequest().authenticated()
                .and()
                .formLogin();
    }

我们逐一来解释一下:
.authorizeRequests(): 表示开始配置访问权限;
.antMatchers("/admin").hasRole("admin"): admin这个endpoint,只有admin身份的用户可以访问,若使用user身份的用户登录时,是无法访问admin这个endpoint的;
.anyRequest().authenticated(): 通常和上述身份配置共同使用,它表示其余的endpoint都需要登录后才能访问,任何一个身份登录后都可以访问。若没有这一配置则其余的endpoint都是public的;
.formLogin(): 表示登录的方式为表单登录,也就是开篇时那个SpringSecurity为我们预制的登录页面,也可以使用其他的表单形式如:.httpBasic()

使用Postman进行登录

以上我们看到的都是在web中的使用方式,那么如何使用Postman进行登录呢?需要对上述配置进行修改:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin").hasRole("admin")
                .antMatchers("/hello").hasRole("user")
                .anyRequest().authenticated()
                .and()
                .formLogin() //if loginPage(String) is not specified a default login page will be generated.
                .loginPage("/login")
                .successHandler((request, response, authentication) -> {
                    RespBean ok = RespBean.ok("登录成功!",authentication.getPrincipal());
                    response.setContentType("application/json;charset=utf-8");
                    PrintWriter out = response.getWriter();
                    out.write(new ObjectMapper().writeValueAsString(ok));
                    out.flush();
                    out.close();
                })
                .failureHandler((request, response, exception) -> {
                    RespBean error = RespBean.error("登录失败!", null);
                    response.setContentType("application/json;charset=utf-8");
                    PrintWriter out = response.getWriter();
                    out.write(new ObjectMapper().writeValueAsString(error));
                    out.flush();
                })
                .permitAll()
                .and()
                .csrf().disable();
    }

在这个方法中配置了一些handler已经登录需要用到的endpoint:/login,对于loginPage这样也进行了声明,并在HelloController中进行了重新的定义,目的是用于覆盖SpringBoot自带的登录页面:

    @GetMapping("/login")
    public RespBean login() {
        return RespBean.error("尚未登录,请登录", null);
    }

并且通过successHandler以及failureHandler重写了登录成功和失败的处理逻辑。.permitAll()用于打开login这个endpoint的访问权限,任何人都可访问它。.csrf().disable()用于关闭CSRF。此时,便可以在Postman中使用Post请求进行登录,此处的编码方式选择为form-data

postman.png

在Postman中便可在同一个session中访问/hello了。

使用JSON的方式进行登录

上面的例子我们发送login请求时,使用的是form-data的编码方式,若想使用JSON格式登录,则需要进行进一步的改写。如果我们打个断点在登录的handler上,可以发现验证用户名密码的功能是由一个UsernamePasswordAuthenticationFilter来处理的,也是由这个Filter来提取form-data中的数据的,若想使用JSON来登录,需要自定义一个Filter:

public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)) {
            ObjectMapper mapper = new ObjectMapper();
            UsernamePasswordAuthenticationToken authRequest = null;
            try (InputStream is = request.getInputStream()) {
                Map<String, String> authenticationBean = mapper.readValue(is, Map.class);
                String username = authenticationBean.get("username");
                String password = authenticationBean.get("password");
                authRequest = new UsernamePasswordAuthenticationToken(username, password);
            } catch (IOException e) {
                e.printStackTrace();
                authRequest = new UsernamePasswordAuthenticationToken("", "");
            } finally {
                setDetails(request, authRequest);
                return this.getAuthenticationManager().authenticate(authRequest);
            }
        } else {
            return super.attemptAuthentication(request, response);
        }
    }
}

在配置类中需要些一个Bean:

    @Bean
    CustomAuthenticationFilter customAuthenticationFilter() throws Exception {
        CustomAuthenticationFilter filter = new CustomAuthenticationFilter();
        filter.setAuthenticationSuccessHandler((request, response, authentication) -> {
            response.setContentType("application/json;charset=utf-8");
            PrintWriter out = response.getWriter();
            RespBean respBean = RespBean.ok("登录成功!", authentication.getPrincipal());
            out.write(new ObjectMapper().writeValueAsString(respBean));
            out.flush();
            out.close();
        });
        filter.setAuthenticationFailureHandler((request, response, exception) -> {
            response.setContentType("application/json;charset=utf-8");
            PrintWriter out = response.getWriter();
            RespBean respBean = RespBean.error("登录失败!", null);
            out.write(new ObjectMapper().writeValueAsString(respBean));
            out.flush();
            out.close();
        });
        filter.setAuthenticationManager(authenticationManagerBean()); // ?
        return filter;
    }

有了这个Bean之后,就可以在config中来替换原来的Filter了:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterAt(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
        http.authorizeRequests()
                .antMatchers("/admin").hasRole("admin")
                .antMatchers("/hello").hasRole("user")
                .anyRequest().authenticated()
                .and()
                .csrf().disable();
    }

此时便可在Postman中使用JSON登录了:


postman.png

集成JWT

在前后端分离的情况下,用户身份的校验通常是基于一个token的,而比较成熟的方案便是JWT,接下来我们看看在SpringSecurity中如何集成JWT。

  1. 引入依赖:
<dependency>
  <groupId>org.projectlombok</groupId>
  <artifactId>lombok</artifactId>
  <optional>true</optional>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt</artifactId>
  <version>0.9.1</version>
</dependency>
  1. 添加Filter
    有了上一小节的实践后,当需要更改登录方式时,可以使用添加Filter的方式,集成JWT也使用了相同的套路。
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("user").password(passwordEncoder().encode("123")).roles("user")
                .and()
                .withUser("admin").password("456").roles("admin");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/hello").hasRole("user")
                .antMatchers("/admin").hasRole("admin")
                .antMatchers(HttpMethod.POST, "/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtLoginFilter("/login",authenticationManager()), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new JwtFilter(), UsernamePasswordAuthenticationFilter.class)
                .csrf().disable();
    }
}

此处,添加了两个Filter:

.addFilterBefore(new JwtLoginFilter("/login",authenticationManager()), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtFilter(), UsernamePasswordAuthenticationFilter.class)

一个用于登录,一个用于登录后验证token的有效性。

public class JwtLoginFilter extends AbstractAuthenticationProcessingFilter {

    protected JwtLoginFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
        super(new AntPathRequestMatcher(defaultFilterProcessesUrl));
        setAuthenticationManager(authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws AuthenticationException, IOException, ServletException {
        User user = new ObjectMapper().readValue(httpServletRequest.getInputStream(), User.class);
        return getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()));
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
        StringBuffer as = new StringBuffer();
        // 将用户角色遍历然后用一个 , 连接起来
        for (GrantedAuthority authority : authorities) {
            as.append(authority.getAuthority()).append(",");
        }
        String jwt = Jwts.builder()
                .claim("authorities", as) // 用户的所有角色,用 , 分割
                .setSubject(authResult.getName())
                .setExpiration(new Date(System.currentTimeMillis() + 10 * 60 * 1000))
                .signWith(SignatureAlgorithm.HS512, "test")
                .compact();
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
        out.write(new ObjectMapper().writeValueAsString(jwt));
        out.flush();
        out.close();
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
        out.write("登录失败!");
        out.flush();
        out.close();
    }
}
public class JwtFilter extends GenericFilterBean {

    // 将提取出来的 token 字符串转换为一个 Claims 对象,
    // 再从 Claims 对象中提取出当前用户名和用户角色,
    // 创建一个 UsernamePasswordAuthenticationToken 放到当前的 Context 中,
    // 然后执行过滤链使请求继续执行下去。
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) servletRequest;
        String jwtToken = req.getHeader("authorization");
        Jws<Claims> jws = Jwts.parser()
                .setSigningKey("test")
                .parseClaimsJws(jwtToken.replace("Bearer", ""));
        Claims claims = jws.getBody();
        String username = claims.getSubject();
        List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList((String) claims.get("authorities"));
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, null, authorities);
        SecurityContextHolder.getContext().setAuthentication(token);
        filterChain.doFilter(servletRequest, servletResponse);
    }
}

同时也需要一个实现UserDetails协议的User类:

@Data
public class User implements UserDetails {

    private String username;
    private String password;
    private List<GrantedAuthority> authorities;

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

此时,便可以使用Postman发起登录请求获取token了:


login.png

登录成功后,可以使用这个token进行访问了:


access by jwt.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容