Spring Security-結合RestfulAPI的設計
By: 陳冠億 Kenny
Tags: Spring Security, Spring Boot, Backend Development, Restful Api, Back End Development

這篇文章主要講解如何透過 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/