在將Keycloak集成到SpringBoot之前,需要先了解一下SpringSecurity。
SpringSecurity 是 Spring 項(xiàng)目組中用來(lái)提供安全認(rèn)證服務(wù)的框架,它對(duì)Web安全性的支持大量地依賴于Servlet過(guò)濾器,也就是Spring的DispatcherServlet,這些過(guò)濾器攔截請(qǐng)求,并且在應(yīng)用程序處理該請(qǐng)求之前進(jìn)行某些安全處理。
啟用SpringSecurity
在SpringBoot項(xiàng)目中,啟用僅需加入依賴即可:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
先寫一個(gè)HelloWorld的Controller:
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello";
}
}
啟動(dòng)應(yīng)用,當(dāng)沒(méi)有配置SpringSecurity的依賴時(shí),通過(guò)瀏覽器訪問(wèn)http://localhost:8080/hello會(huì)直接顯示hello這個(gè)字符串,而在加入SpringSecurity的依賴后,頁(yè)面會(huì)自動(dòng)跳轉(zhuǎn)到http://localhost:8080/login,頁(yè)面如下圖所示:

此時(shí),我們并沒(méi)有配置任何用戶信息,SpringSecurity為該項(xiàng)目添加了一個(gè)默認(rèn)的用戶,用戶名為:
user,而密碼可以在啟動(dòng)的控制臺(tái)內(nèi)看到:
...
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'
...
在輸入完用戶名密碼后,頁(yè)面會(huì)跳轉(zhuǎn)至http://localhost:8080/hello,并顯示hello這個(gè)字符串。若打開(kāi)Chrome的調(diào)試工具,可以看到如下:

在/hello這個(gè)請(qǐng)求中帶著這樣一段Cookie,這段Cookie就是在login成功后被設(shè)置的。
添加用戶
接下來(lái),我們?yōu)檫@個(gè)應(yīng)用添加一些默認(rèn)的用戶,添加用戶的方式共有三種:
- 基于memory db的用戶
- 在application.properties中配置
- 從db中讀取
鑒于現(xiàn)在對(duì)SpringSecurity的了解是為了之后集成Keycloak,因此此處使用了第一種方式,也是較為簡(jiǎn)單的方式。為了添加用戶,我們需要對(duì)SpringSecurity進(jìn)行配置,需要編譯一個(gè)繼承自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"));
}
}
實(shí)現(xiàn)void configure(AuthenticationManagerBuilder auth)方法,在其中進(jìn)行配置用戶信息,在此配置了兩個(gè)用戶分別為user和admin。而上方的那個(gè)@Bean是用來(lái)給密碼加密/加鹽的。
在添加了這個(gè)配置類后,重啟應(yīng)用,此時(shí)在控制臺(tái)中就不再有默認(rèn)的密碼信息了,再次訪問(wèn)/hello,就可以通過(guò)剛剛配置的兩個(gè)用戶進(jìn)行登錄了。
忽略某些endpoint
在我們沒(méi)有進(jìn)行任何配置的情況下,SpringSecurity將保護(hù)所有的endpoint,通常情況下,我們是需要暴露某些endpoint的,此時(shí)就需要實(shí)現(xiàn)配置類的另一個(gè)方法:
...
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/yo");
}
...
看方法名即可看出,將忽略掉/yo這個(gè)endpoint。
根據(jù)role來(lái)匹配可以訪問(wèn)的endpoint
在void configure(AuthenticationManagerBuilder auth)配置中,我們配置了兩個(gè)用戶,并且分別給與了兩個(gè)不同的身份,若想根據(jù)身份的不同來(lái)限制訪問(wèn),就需要實(shí)現(xiàn)另一個(gè)方法了:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin").hasRole("admin")
.antMatchers("/hello").hasRole("user")
.anyRequest().authenticated()
.and()
.formLogin();
}
我們逐一來(lái)解釋一下:
.authorizeRequests(): 表示開(kāi)始配置訪問(wèn)權(quán)限;
.antMatchers("/admin").hasRole("admin"): admin這個(gè)endpoint,只有admin身份的用戶可以訪問(wèn),若使用user身份的用戶登錄時(shí),是無(wú)法訪問(wèn)admin這個(gè)endpoint的;
.anyRequest().authenticated(): 通常和上述身份配置共同使用,它表示其余的endpoint都需要登錄后才能訪問(wèn),任何一個(gè)身份登錄后都可以訪問(wèn)。若沒(méi)有這一配置則其余的endpoint都是public的;
.formLogin(): 表示登錄的方式為表單登錄,也就是開(kāi)篇時(shí)那個(gè)SpringSecurity為我們預(yù)制的登錄頁(yè)面,也可以使用其他的表單形式如:.httpBasic();
使用Postman進(jìn)行登錄
以上我們看到的都是在web中的使用方式,那么如何使用Postman進(jìn)行登錄呢?需要對(duì)上述配置進(jìn)行修改:
@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();
}
在這個(gè)方法中配置了一些handler已經(jīng)登錄需要用到的endpoint:/login,對(duì)于loginPage這樣也進(jìn)行了聲明,并在HelloController中進(jìn)行了重新的定義,目的是用于覆蓋SpringBoot自帶的登錄頁(yè)面:
@GetMapping("/login")
public RespBean login() {
return RespBean.error("尚未登錄,請(qǐng)登錄", null);
}
并且通過(guò)successHandler以及failureHandler重寫了登錄成功和失敗的處理邏輯。.permitAll()用于打開(kāi)login這個(gè)endpoint的訪問(wèn)權(quán)限,任何人都可訪問(wèn)它。.csrf().disable()用于關(guān)閉CSRF。此時(shí),便可以在Postman中使用Post請(qǐng)求進(jìn)行登錄,此處的編碼方式選擇為form-data

在Postman中便可在同一個(gè)session中訪問(wèn)
/hello了。
使用JSON的方式進(jìn)行登錄
上面的例子我們發(fā)送login請(qǐng)求時(shí),使用的是form-data的編碼方式,若想使用JSON格式登錄,則需要進(jìn)行進(jìn)一步的改寫。如果我們打個(gè)斷點(diǎn)在登錄的handler上,可以發(fā)現(xiàn)驗(yàn)證用戶名密碼的功能是由一個(gè)UsernamePasswordAuthenticationFilter來(lái)處理的,也是由這個(gè)Filter來(lái)提取form-data中的數(shù)據(jù)的,若想使用JSON來(lái)登錄,需要自定義一個(gè)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);
}
}
}
在配置類中需要些一個(gè)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;
}
有了這個(gè)Bean之后,就可以在config中來(lái)替換原來(lái)的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();
}
此時(shí)便可在Postman中使用JSON登錄了:

集成JWT
在前后端分離的情況下,用戶身份的校驗(yàn)通常是基于一個(gè)token的,而比較成熟的方案便是JWT,接下來(lái)我們看看在SpringSecurity中如何集成JWT。
- 引入依賴:
<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>
- 添加Filter
有了上一小節(jié)的實(shí)踐后,當(dāng)需要更改登錄方式時(shí),可以使用添加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();
}
}
此處,添加了兩個(gè)Filter:
.addFilterBefore(new JwtLoginFilter("/login",authenticationManager()), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtFilter(), UsernamePasswordAuthenticationFilter.class)
一個(gè)用于登錄,一個(gè)用于登錄后驗(yàn)證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();
// 將用戶角色遍歷然后用一個(gè) , 連接起來(lái)
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 {
// 將提取出來(lái)的 token 字符串轉(zhuǎn)換為一個(gè) Claims 對(duì)象,
// 再?gòu)?Claims 對(duì)象中提取出當(dāng)前用戶名和用戶角色,
// 創(chuàng)建一個(gè) UsernamePasswordAuthenticationToken 放到當(dāng)前的 Context 中,
// 然后執(zhí)行過(guò)濾鏈?zhǔn)拐?qǐng)求繼續(xù)執(zhí)行下去。
@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);
}
}
同時(shí)也需要一個(gè)實(shí)現(xiàn)UserDetails協(xié)議的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;
}
}
此時(shí),便可以使用Postman發(fā)起登錄請(qǐng)求獲取token了:

登錄成功后,可以使用這個(gè)token進(jìn)行訪問(wèn)了:
