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

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

SpringSecurity 是 Spring 項目組中用來提供安全認證服務(wù)的框架,它對Web安全性的支持大量地依賴于Servlet過濾器尝哆,也就是Spring的DispatcherServlet帕棉,這些過濾器攔截請求空盼,并且在應(yīng)用程序處理該請求之前進行某些安全處理亩歹。

啟用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";
    }
}

啟動應(yīng)用,當(dāng)沒有配置SpringSecurity的依賴時捆憎,通過瀏覽器訪問http://localhost:8080/hello會直接顯示hello這個字符串舅柜,而在加入SpringSecurity的依賴后,頁面會自動跳轉(zhuǎn)到http://localhost:8080/login躲惰,頁面如下圖所示:

login.png

此時,我們并沒有配置任何用戶信息变抽,SpringSecurity為該項目添加了一個默認的用戶础拨,用戶名為:user,而密碼可以在啟動的控制臺內(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'
...

在輸入完用戶名密碼后绍载,頁面會跳轉(zhuǎn)至http://localhost:8080/hello诡宗,并顯示hello這個字符串。若打開Chrome的調(diào)試工具击儡,可以看到如下:

chrome.png

在/hello這個請求中帶著這樣一段Cookie塔沃,這段Cookie就是在login成功后被設(shè)置的。

添加用戶

接下來阳谍,我們?yōu)檫@個應(yīng)用添加一些默認的用戶蛀柴,添加用戶的方式共有三種:

  1. 基于memory db的用戶
  2. 在application.properties中配置
  3. 從db中讀取

鑒于現(xiàn)在對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"));
    }
}

實現(xiàn)void configure(AuthenticationManagerBuilder auth)方法冒窍,在其中進行配置用戶信息,在此配置了兩個用戶分別為user和admin豺鼻。而上方的那個@Bean是用來給密碼加密/加鹽的综液。

在添加了這個配置類后,重啟應(yīng)用儒飒,此時在控制臺中就不再有默認的密碼信息了谬莹,再次訪問/hello,就可以通過剛剛配置的兩個用戶進行登錄了约素。

忽略某些endpoint

在我們沒有進行任何配置的情況下届良,SpringSecurity將保護所有的endpoint,通常情況下圣猎,我們是需要暴露某些endpoint的士葫,此時就需要實現(xiàn)配置類的另一個方法:

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

看方法名即可看出,將忽略掉/yo這個endpoint送悔。

根據(jù)role來匹配可以訪問的endpoint

void configure(AuthenticationManagerBuilder auth)配置中慢显,我們配置了兩個用戶,并且分別給與了兩個不同的身份欠啤,若想根據(jù)身份的不同來限制訪問荚藻,就需要實現(xiàn)另一個方法了:

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

我們逐一來解釋一下:
.authorizeRequests(): 表示開始配置訪問權(quán)限;
.antMatchers("/admin").hasRole("admin"): admin這個endpoint洁段,只有admin身份的用戶可以訪問应狱,若使用user身份的用戶登錄時,是無法訪問admin這個endpoint的祠丝;
.anyRequest().authenticated(): 通常和上述身份配置共同使用疾呻,它表示其余的endpoint都需要登錄后才能訪問,任何一個身份登錄后都可以訪問写半。若沒有這一配置則其余的endpoint都是public的岸蜗;
.formLogin(): 表示登錄的方式為表單登錄,也就是開篇時那個SpringSecurity為我們預(yù)制的登錄頁面叠蝇,也可以使用其他的表單形式如:.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已經(jīng)登錄需要用到的endpoint:/login枚冗,對于loginPage這樣也進行了聲明,并在HelloController中進行了重新的定義蛇损,目的是用于覆蓋SpringBoot自帶的登錄頁面:

    @GetMapping("/login")
    public RespBean login() {
        return RespBean.error("尚未登錄赁温,請登錄", null);
    }

并且通過successHandler以及failureHandler重寫了登錄成功和失敗的處理邏輯坛怪。.permitAll()用于打開login這個endpoint的訪問權(quán)限,任何人都可訪問它股囊。.csrf().disable()用于關(guān)閉CSRF袜匿。此時,便可以在Postman中使用Post請求進行登錄稚疹,此處的編碼方式選擇為form-data

postman.png

在Postman中便可在同一個session中訪問/hello了居灯。

使用JSON的方式進行登錄

上面的例子我們發(fā)送login請求時,使用的是form-data的編碼方式内狗,若想使用JSON格式登錄怪嫌,則需要進行進一步的改寫。如果我們打個斷點在登錄的handler上柳沙,可以發(fā)現(xiàn)驗證用戶名密碼的功能是由一個UsernamePasswordAuthenticationFilter來處理的岩灭,也是由這個Filter來提取form-data中的數(shù)據(jù)的,若想使用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
    有了上一小節(jié)的實踐后泡孩,當(dāng)需要更改登錄方式時车摄,可以使用添加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 字符串轉(zhuǎn)換為一個 Claims 對象敞贡,
    // 再從 Claims 對象中提取出當(dāng)前用戶名和用戶角色泵琳,
    // 創(chuàng)建一個 UsernamePasswordAuthenticationToken 放到當(dāng)前的 Context 中,
    // 然后執(zhí)行過濾鏈使請求繼續(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);
    }
}

同時也需要一個實現(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;
    }
}

此時获列,便可以使用Postman發(fā)起登錄請求獲取token了:


login.png

登錄成功后,可以使用這個token進行訪問了:


access by jwt.png
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子火焰,更是在濱河造成了極大的恐慌光酣,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,496評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件涯曲,死亡現(xiàn)場離奇詭異沪蓬,居然都是意外死亡肋僧,警方通過查閱死者的電腦和手機括蝠,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,407評論 3 392
  • 文/潘曉璐 我一進店門鞠抑,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人忌警,你說我怎么就攤上這事搁拙。” “怎么了法绵?”我有些...
    開封第一講書人閱讀 162,632評論 0 353
  • 文/不壞的土叔 我叫張陵箕速,是天一觀的道長。 經(jīng)常有香客問我朋譬,道長盐茎,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,180評論 1 292
  • 正文 為了忘掉前任此熬,我火速辦了婚禮庭呜,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘犀忱。我一直安慰自己募谎,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,198評論 6 388
  • 文/花漫 我一把揭開白布阴汇。 她就那樣靜靜地躺著数冬,像睡著了一般。 火紅的嫁衣襯著肌膚如雪搀庶。 梳的紋絲不亂的頭發(fā)上拐纱,一...
    開封第一講書人閱讀 51,165評論 1 299
  • 那天,我揣著相機與錄音哥倔,去河邊找鬼秸架。 笑死,一個胖子當(dāng)著我的面吹牛咆蒿,可吹牛的內(nèi)容都是我干的东抹。 我是一名探鬼主播,決...
    沈念sama閱讀 40,052評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼沃测,長吁一口氣:“原來是場噩夢啊……” “哼缭黔!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起蒂破,我...
    開封第一講書人閱讀 38,910評論 0 274
  • 序言:老撾萬榮一對情侶失蹤馏谨,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后附迷,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體惧互,經(jīng)...
    沈念sama閱讀 45,324評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡哎媚,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,542評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了壹哺。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片抄伍。...
    茶點故事閱讀 39,711評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖管宵,靈堂內(nèi)的尸體忽然破棺而出截珍,到底是詐尸還是另有隱情,我是刑警寧澤箩朴,帶...
    沈念sama閱讀 35,424評論 5 343
  • 正文 年R本政府宣布岗喉,位于F島的核電站,受9級特大地震影響炸庞,放射性物質(zhì)發(fā)生泄漏钱床。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,017評論 3 326
  • 文/蒙蒙 一埠居、第九天 我趴在偏房一處隱蔽的房頂上張望查牌。 院中可真熱鬧,春花似錦滥壕、人聲如沸纸颜。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,668評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽胁孙。三九已至,卻和暖如春称鳞,著一層夾襖步出監(jiān)牢的瞬間涮较,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,823評論 1 269
  • 我被黑心中介騙來泰國打工冈止, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留狂票,地道東北人。 一個月前我還...
    沈念sama閱讀 47,722評論 2 368
  • 正文 我出身青樓熙暴,卻偏偏與公主長得像苫亦,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子怨咪,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,611評論 2 353

推薦閱讀更多精彩內(nèi)容