Spring Security-結合RestfulAPI的設計

陳冠億 Kenny
企鵝也懂程式設計
29 min readSep 4, 2019

--

這篇文章主要講解如何透過Spring Security框架來結合RestfulApi的設計,傳統的Spring Security其實是針對一般的MVC去設計的,大部分網路上的範例也是用MVC去做設定,但現在由於是流行Restful的趨勢,加上目前我在寫專案的時候也是遇到要結合RestfulApi設計的問題,因此在這邊做個紀錄及教學。

建立Springboot專案

首先建立一個Springboot專案,裡面要包含Web、Security等的dependency。

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

建立Spring Security設定檔之class

建立一個class,假設叫做SecurityConfig,要去extends WebSecurityConfigurerAdapter。這個Adapter我們可以想成它是一個Security基底的class,我們可以藉由去繼承它來Override裡面的一些設定,進而達到客製化的功能。

程式碼如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
}
}

其中我們選擇Override兩個configure的method,其兩個method裡面兩個參數是有不同的意義。

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

這個代表是相關使用者的認證設定,比如說我們在這邊設定當專案啟動時,內存幫我們存放兩名使用者的帳號及密碼,其角色所代表的分別是我們定義的ADMIN、USER。這個方式是方便我們去做測試用,而不用特地開資料庫並且開表格去存放這些使用者的資料。當然這個method裡面也可以設定如何連接到我們資料庫,拿取使用者的帳號密碼並進行登入的比對。在下篇文章會在講解這個地方。

@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
}

這個method可以設定那些路由要經過身分權限的審核,或是login、logout路由特別設定等地方,因此這邊也是設定身分權限的關鍵地方。

但現在我們先不動這邊。我們先來想想,一個rest api,可能會有login的需求,在這邊不考慮嚴謹的Restful Api,因為嚴謹的定義,是不應該用login,更不應該有session,因為是無狀態的。而是採用JWT的方式,這有機會再另外開一篇文去說明。我個人覺得,如果是純開發網頁,採用前後端分離的話,如果要用restful api的形式,其實是會用的有點彆扭。必要的時候還是可以不遵照嚴謹的RestfulApi的形式,而是採用一半一半的方式,因此這邊我們會採用傳統的login,也就是說會有session產生的。

但這邊會出現一個問題,就是傳統的Spring Security都會有login的預設頁面,如下:

這是傳統的Spring Security的登入畫面,但現在我們是採用Restful的形式,也就是說在前後端分離的情況下,前端會有自己的login頁面,並且登入的時候發送api給Backend去做登入驗證。而傳輸的方式,是使用json的格式,而非傳統的MVC的方式。

所以後端有以下兩個地方要解決

  • 當使用者透過API訪問工具去訪問後端API,但實際上使用者尚未登入,所以後端直接回覆json格式,阻擋使用者進行未登入的操作,而非跳回傳統Spring Security頁面。
  • 前端可以用自己的login頁面,並且call login api,而非使用傳統Spring Security頁面。

解決Restful登入登出要回傳json格式的問題

第一個解決方案,首先我們建立RestAuthenticationEntryPoint之Class,並且implements AuthenticationEntryPoint。

程式碼如下:

public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
ObjectMapper mapper = new ObjectMapper();
Map<String, String> map = Map.of("error", "請先登入才能進行此操作");
String error = mapper.writeValueAsString(map);
httpServletResponse.setContentType("application/json;charset=UTF-8");
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setStatus(httpServletResponse.SC_UNAUTHORIZED);
PrintWriter writer = httpServletResponse.getWriter();
writer.write(error);
writer.flush();
writer.close();
}
}

這個AuthenticationEntryPoint的Class主要是用來設定使用者的權限進入點,也就是說使用者要使用,一定要先經過登入審核的動作,因此在這邊我們就可以做個手腳,不採用傳統Spring Security的Login頁面,透過Override commerce這個method,去做設定,讓它可以直接回覆json格式。

這邊還有一個好處,也是前端不必再特別在每個頁面call api去詢問後端,是否這個人處在登入狀態。因為這邊後端完全都擋好了,前端只需要判斷每隻API回傳401 status的可能性就可以了。只要出現401即可導入到登入頁面。

第二個解決方案,顧名思義,我們需要建立login的Api路由。

首先建立LoginAuthenticationFilter去extends UsernamePasswordAuthenticationFilter。

程式碼如下:

public class LoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

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

這個Filter主要功用就是當使用者登入後,會經過的Filter,它會去取得裡面的request的參數,因此在這邊我們可以更動的就是如何去取得request參數,因為改成Restful的形式,因此會先判斷request過來的形式是不是json的格式,接著取得request的輸入流,這個就是我們的body內容,因此可以將它轉成Map格式,在方便我們取得裡面的參數。也因此參數這邊我們可以取成account、password,而不是傳統的username、password,我們可以自定義。

接著我們需要定義,當登入後需要回傳成功的json訊息跟失敗的json訊息:

建立AuthenticationSuccessHandlerImpl去implements這個AuthenticationSuccessHandler的class

程式碼如下:

public class AuthenticationSuccessHandlerImpl implements AuthenticationSuccessHandler {

private final String LOGGED_IN = "logged_in";
private final String USER_TYPE = "user_type";

@Override
public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
String account = authentication.getName();
Collection collection = authentication.getAuthorities();
String authority = collection.iterator().next().toString();
HttpSession session = req.getSession();
session.setAttribute(LOGGED_IN, account);
session.setAttribute(USER_TYPE, authority);
Map<String, String> result = new HashMap<>();
result.put("authority", authority);
resp.setContentType("application/json;charset=UTF-8");
PrintWriter out = resp.getWriter();
resp.setStatus(200);
ObjectMapper om = new ObjectMapper();
out.write(om.writeValueAsString(result));
out.flush();
out.close();
}
}

這個Handler就是專門處理當登入成功後,可以採取什麼動作。同時也可以利用Authentication物件取得account、authority。這個handler可以做兩件事情:

  • 我們需要session去存放account、authority,以便之後如何判斷過來的request。這並不符合Restful的形式,所以這邊是採用混合的方式。因此在這邊也可以透過request去取得session,再將account、authority,去放置在session的attribute裡面。
  • 再來這邊可以定義成功登入之後要回復的json格式,那因為是採前後端分離的格式,也就是說前端需要身分的訊息,它才能控制之後要呈現的頁面是給哪個身分的角色去看,所以我們可以回傳角色身分的資訊。

接著是要處理登入失敗的handler,建立AuthenticationFailureHandlerImpl去implements這個AuthenticationFailureHandler的class。

程式碼如下:

public class AuthenticationFailureHandlerImpl implements AuthenticationFailureHandler {

@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
httpServletResponse.setContentType("application/json;charset=UTF-8");
PrintWriter out = httpServletResponse.getWriter();
httpServletResponse.setStatus(404);
Map<String, String> result = Map.of("message", "登入失敗");
ObjectMapper om = new ObjectMapper();
out.write(om.writeValueAsString(result));
out.flush();
out.close();
}
}

這邊就是回復失敗的json訊息就可以了。

解決Restful上身分權限存取API的問題

以上的步驟是解決登入登出,及未經登入就存取API的問題。但還有一個問題是,使用者的身分問題,也就是說這個使用者身分是USER卻要去存取ADMIN的相關API。以前端來說就變成是,身分是USER卻想存取ADMIN相關頁面。

因此我們需要做一些手腳:

建立AccessDeniedHandlerImpl去implements此AccessDeniedHandler的class。

程式碼如下:

public class AccessDeniedHandlerImpl implements AccessDeniedHandler {

@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
throws IOException, ServletException {
ObjectMapper mapper = new ObjectMapper();
Map<String, String> result = Map.of("message", "你無權限可執行該動作!");
response.setContentType("application/json;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
PrintWriter out = response.getWriter();
out.write(mapper.writeValueAsString(result));
out.flush();
out.close();
}
}

這個Handler主要就是當Spring Security在前面幫你判斷是身分錯誤的時候,會經過的handler,因此在這邊我們就能定義要回復的json訊息,並回傳status code = 403。

Spring Security for Restful設定檔之撰寫

當以上步驟全部都完成後,接著我們就能來一步一步來設定我們的Spring Security的設定檔。

程式碼如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}

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

@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling()
.authenticationEntryPoint(new RestAuthenticationEntryPoint())
.accessDeniedHandler(new AccessDeniedHandlerImpl())
.and()
.addFilterAt(loginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.antMatchers("/api/user/**").hasRole("USER")
.and()
.logout()
.logoutUrl("/api/logout")
.invalidateHttpSession(true)
.logoutSuccessHandler((req, resp, auth) -> {
resp.setContentType("application/json;charset=UTF-8");
PrintWriter out = resp.getWriter();
resp.setStatus(200);
Map<String, String> result = Map.of("message", "登出成功");
ObjectMapper om = new ObjectMapper();
out.write(om.writeValueAsString(result));
out.flush();
out.close();
})
.and()
.csrf()
.disable();
}

@Bean
LoginAuthenticationFilter loginAuthenticationFilter() throws Exception {
LoginAuthenticationFilter filter = new LoginAuthenticationFilter();
filter.setAuthenticationManager(authenticationManagerBean());
filter.setAuthenticationSuccessHandler(new AuthenticationSuccessHandlerImpl());
filter.setAuthenticationFailureHandler(new AuthenticationFailureHandlerImpl());
filter.setFilterProcessesUrl("/api/login");
return filter;
}

@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}

看起來很雜,但其實都是把我們剛剛寫好的組件慢慢組上去而已。我們來一個一個來講。

@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}

這個剛剛沒說到,但這個是必加的,也就是說Spring Security強制指定你必須決定使用者的密碼你要採取哪種編碼方式,簡單來說就是加密的演算法要選擇哪個。當你選擇之後,Spring Security就會自動幫你加密。這邊為了測試方便,可以選擇NoOpPasswordEncoder.getInstance();的方式,也就是不加密,當然這個方式是不好的,在實際開發並不會採取該方式。但因為我們現在並沒有加入資料庫,而是利用內建記憶體去建立出角色的資料。之後有機會再開加入資料庫的教學。

@Bean
LoginAuthenticationFilter loginAuthenticationFilter() throws Exception {
LoginAuthenticationFilter filter = new LoginAuthenticationFilter();
filter.setAuthenticationManager(authenticationManagerBean());
filter.setAuthenticationSuccessHandler(new AuthenticationSuccessHandlerImpl());
filter.setAuthenticationFailureHandler(new AuthenticationFailureHandlerImpl());
filter.setFilterProcessesUrl("/api/login");
return filter;
}

@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

我們先從下面這兩塊開始講起,這邊簡單來說就是建立 LoginAuthenticationFilter的bean,讓Spring Security可以採用,而在裡面就是建立的LoginAuthenticationFilter物件,並且設定成功及失敗的handler,就是加我們剛剛新增那兩個handler。最後最重要的是我們要設定該filter是專門for 哪個路由,也就是這邊我們可以自定義login的路由,而不是採用傳統的Spring Security提供的表單路由。

至於下面的AuthenticationManager,因為filter需要有AuthenticationManager的設定,但是用原本Spring Security的即可。因為沒設定的話,會報錯誤,而且給的錯誤訊息就是需要去設定AuthenticationManager。

當以上的步驟都好了之後我們再來看以下的設定:

@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling()
.authenticationEntryPoint(new RestAuthenticationEntryPoint())
.accessDeniedHandler(new AccessDeniedHandlerImpl())
.and()
.addFilterAt(loginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.antMatchers("/api/user/**").hasRole("USER")
.and()
.logout()
.logoutUrl("/api/logout")
.invalidateHttpSession(true)
.logoutSuccessHandler((req, resp, auth) -> {
resp.setContentType("application/json;charset=UTF-8");
PrintWriter out = resp.getWriter();
resp.setStatus(200);
Map<String, String> result = Map.of("message", "登出成功");
ObjectMapper om = new ObjectMapper();
out.write(om.writeValueAsString(result));
out.flush();
out.close();
})
.and()
.csrf()
.disable();
}

這個configure,就是將剛剛我們寫好的組件加入進去設定裡面,才能生效。

從一開始的exceptionHanling可以拿來設定authenticationEntryPoint、accessDeniedHandler,我們只要換成我們定義的

RestAuthenticationEntryPoint、AccessDeniedHandlerImpl即可。

接著是新增filter,也就是把我們的loginAuthenticationFilter跟原始的UsernamePasswordAuthenticationFilter去做替換,才能實現我們想要的功能。

再來主要是講解定義能夠存取路由的角色配置:

.authorizeRequests()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.antMatchers("/api/user/**").hasRole("USER")

這個方式,就是設定每個Requests都必須經過身分驗證的手續,再來每個路由可以設定其對應的那些身分權限可以存取。antMachters這個Mapping方式,簡單來區分的話就是如果定義/api/admin/**,的意思就是在/api/admin/路徑下不管是/api/admin/example、/api/admin/example2,都只能由hasRole(“ADMIN”),才能存取。如果要定義明確路由只能由哪個角色存取,可以寫成.antMatchers(“/api/admin/hello”).hasRole(“ADMIN”)。而這個Role的名稱就是對應到,我們上面的configure的設定。也就是:

auth.inMemoryAuthentication()
.withUser("admin").password("123").roles("ADMIN")
.and()
.withUser("user").password("123").roles("USER");

接著看logout的配置:

.logout()
.logoutUrl("/api/logout")
.invalidateHttpSession(true)
.logoutSuccessHandler((req, resp, auth) -> {
resp.setContentType("application/json;charset=UTF-8");
PrintWriter out = resp.getWriter();
resp.setStatus(200);
Map<String, String> result = Map.of("message", "登出成功");
ObjectMapper om = new ObjectMapper();
out.write(om.writeValueAsString(result));
out.flush();
out.close();
})

這邊就是可以定義登出的路由,以及可以幫我們主動將session destory掉,還有可以添加登出成功的handler,這邊也就是能自訂我們想要的json回復訊息。當然這邊也可以另外寫一個class去implements這個handler的interface。為了快速可以改成lambda的方式寫。

最後面主要是資安的東西,也就是防止csrf。

以上的東西都設定好後,我們就可以來撰寫一個api來做測試。

程式碼如下:

@RestController
@RequestMapping("/api")
public class Controller {

@GetMapping("/admin/hello")
public Map<String, String> adminSayHello() {
Map<String, String> result = Map.of("message", "admin say hello");
return result;
}

@GetMapping("/user/hello")
public Map<String, String> userSayHello() {
Map<String, String> result = Map.of("message", "user say hello");
return result;
}
}

假設建立了一個Controller的class並且標註為RestController的形式,我們建立兩個api,一個是在admin下面,一個是在user下面。

寫完之後,開始運行我們來測試看看。

未登入的狀態下,存取這兩個API:

會得到請先登入才能進行此操作的訊息。

接著我們存取登入的API:

我們用admin的身分去登入,的確可以得到登入後成功的訊息。

接著我們用admin的身分去存取user的API:

會得到沒有權限的訊息,沒有錯,符合我們Spring Security的設定。

接著存取admin的API:

可以成功存取到,因為是符合權限的。

接著存取登出的API:

得到登出成功的訊息。

總結

以上的方式就是讓Spring Security結合RestfulApi的設計,當然這並不是很符合Restful的精神,下次有機會帶來利用JWT方式的RestfulApi的設計。此外,Spring Security還可以結合資料庫進行角色存取的設計,這也是之後有機會再寫文分享了。

歡迎來我的個人部落格觀看: https://kennychen-blog.herokuapp.com/

--

--