Spring Boot - Spring Security 結合 JWT 的設計來限制 API 存取

在現今前後端分離的趨勢下,JWT 是必須學會應用的,今天紀錄如何透過 Spring Boot 裡面的 Spring Security 結合 JWT 的機制來限制 API 存取。

Spring Security 是一個安全的框架,裡面提供了非常多的功能也很複雜,之後會花比較多篇文章來記錄 Spring Security 如何使用,今天重點主要是在如何去更改 Spring Security 裡面的元件再配合 JWT 的機制。

先來講解專案目錄

  • controller

    主要有以下兩種

    1. AuthController

      用來獲取合法的 JWT

    2. UserController

      提供 User 的 CRUD 操作

  • lib

    封裝 JWT 程式庫的操作,使用的程式庫為 JJWT,如果不熟悉,請點擊這裡

  • model

    定義 User Class,對應於資料庫的 table,也定義了一些針對 HTTP request 的 Class

  • repository

    定義 UserRepository,為 JPA 的接口,用來操作 CRUD

  • security

    定義相關 Spring Security 的操作

  • service

    封裝 Auth、User 的相關 Service,提供給 controller 操作

此次使用的資料庫是採用 PostgreSQL,其 create table shema 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
--the script to remove all tables in the database
DROP TABLE IF EXISTS users CASCADE;

create table users
(
id uuid,
email character varying(200) not null,
password_digest character varying(1000) not null,
name character varying(255) not null,

CONSTRAINT "users_pk" PRIMARY KEY (id)
);

ALTER TABLE users ADD CONSTRAINT users_u1 UNIQUE (email);

設定 Spring Security

直接上程式碼~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

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

private AuthService authService;
private JWTAuthenticationFilter jwtAuthenticationFilter;

@Autowired
public SecurityConfig(AuthService authService, JWTAuthenticationFilter jwtAuthenticationFilter) {
this.authService = authService;
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/auth/").permitAll()
.antMatchers(HttpMethod.POST, "/v1/users/**").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(authService).passwordEncoder(passwordEncoder());
}
}

一一講解

  • 密碼雜湊設定

    1
    2
    3
    4
    @Bean
    public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
    }

    這邊採用 BCrypt 演算法進行雜湊

  • 提供管理 Authentication 元件

    1
    2
    3
    4
    5
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
    }

    這個如果沒給的話,會報錯

  • 提供 AuthService 跟 JWTAuthenticationFilter 元件

    1
    2
    3
    4
    5
    6
    7
    8
    private AuthService authService;
    private JWTAuthenticationFilter jwtAuthenticationFilter;

    @Autowired
    public SecurityConfig(AuthService authService, JWTAuthenticationFilter jwtAuthenticationFilter) {
    this.authService = authService;
    this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    透過 Autowired,inject 進來使用,一個是為了 implement UserDetailsService,一個是用來處理每次請求要檢驗 JWT token 的 filter

  • 將 authService 跟 passwordEncoder 設定 Spring Security 進去

    1
    2
    3
    4
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(authService).passwordEncoder(passwordEncoder());
    }

    設定為自定義的元件

  • 設定 HTTP 路由相關設定

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable()
    .authorizeRequests()
    .antMatchers(HttpMethod.POST, "/auth/").permitAll()
    .antMatchers(HttpMethod.POST, "/v1/users/**").permitAll()
    .anyRequest().authenticated()
    .and()
    .sessionManagement()
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    .and()
    .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
    • 因為有 JWT,所以可以防範 csrf 攻擊,因此將其 disable
    • 為了示範,這邊只有 users 及 auth 路由,其他 auth 路由是為了拿取合法的 token,因此並無限制存取,只需提供帳號及密碼,而 users 路由除了建立 user 不需要合法 token,其餘的路由皆須要有合法的路由,因為建立 user 可以看成是註冊的 API,因此不應限制存取。
    • 因為是 JWT 機制,所以不應該產生 Session,因此將 Session 建立取消
    • 因為每個受保護的 API 請求都需要去檢查其 Header 上面的 token 是否合法,因此添加了一個自定義的 jwt filter 在 UsernamePasswordAuthenticationFilter 前面,這樣可以避免用原生 Spring Security 的方式幫我們做檢查,而是採用我們自定義的方式

如何取得合法的 JWT Token

先來看封裝好的 JWT 程式庫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Component
@ConfigurationProperties(prefix = "jwt")
@Data
public class JWTUtil {

private String secretKey;
private int lifeTime;

public String Sign(String userId) {
Claims claims = Jwts.claims();
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.SECOND, lifeTime);
claims.setExpiration(calendar.getTime());
claims.put("userId", userId);

Key key = Keys.hmacShaKeyFor(secretKey.getBytes());
return Jwts.builder().setClaims(claims).signWith(key, SignatureAlgorithm.HS256).compact();
}

public String Verify(String token) {
String userId;
try {
Key key = Keys.hmacShaKeyFor(secretKey.getBytes());
Claims claims = Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody();
userId = claims.get("userId").toString();
} catch (Exception e) {
userId = null;
}
return userId;
}
}

主要就是將 userId 簽署到 token 裡面,以供之後 CRUD 之用~

而驗證的話,就是 try 看有沒有 exception,如果沒有且裡面的 userId 不是 null 的話即為合法並回傳 userId

而 secretKey 跟 lifeTime 則定義在 application.properties 裡面。

AuthService 講解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Service
public class AuthService implements UserDetailsService {

@Autowired
private UserRepository userRepository;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JWTUtil jwtUtil;

@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Optional<User> instance = userRepository.findByEmail(email);
if (instance.isPresent()) {
return instance.get();
}
throw new UsernameNotFoundException("email not found");
}

public ResponseEntity<?> getToken(AuthRequest authRequest) {
try {
authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(authRequest.getEmail(), authRequest.getPassword()));
User user = userRepository.findByEmail(authRequest.getEmail()).get();
String token = jwtUtil.Sign(user.getId().toString());
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", token);
return ResponseEntity.ok().headers(headers).body(null);
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null);
}
}
}
  • loadUserByUsername

    主要是為了讓 Spring Security 透過 Username,這邊是用 email 當作 Username,並且利用 userRepository 存取資料庫並拿取該 user 的資料,如果沒有代表不存在此 user。

  • getToken

    1
    authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(authRequest.getEmail(), authRequest.getPassword()));

    前面這個是為了透過拿到 HTTP request 拿到的帳號密碼,建立 UsernamePasswordAuthenticationToken 物件,並讓 authenticationManager 進行驗證,它就會 call 前面 loadUserByUsername 來拿 User 的資料,如果有拿到就會去比對其密碼跟輸入的密碼是否一樣

    所以如果驗證失敗就會抓到 exception,因此在這邊呢,就可以利用 JWT 的程式庫,來給予合法的 token。

如何驗證 JWT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class JWTAuthenticationFilter extends OncePerRequestFilter {

@Autowired
private JWTUtil jwtUtil;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null) {
String userId = jwtUtil.Verify(authHeader.replace("Bearer ", ""));

if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(userId, null, null);
usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
chain.doFilter(request, response);
}
}

將這個 filter 去繼承 OncePerRequestFilter,也就是說每個進來的 Request 都會經過此 filter,但是首先判斷 Authorization 是否為空,如果為空代表一定是不合法的 Request,或是存取不受保護的 API。

如果不為空,則用 JWT 的檢查機制進行檢查,如果也沒錯則取出裡面的 userId,並建立 UsernamePasswordAuthenticationToken 物件放入 userId。

最後 SecurityContextHolder.getContext ().setAuthentication (usernamePasswordAuthenticationToken);

這個是一個 Security 的上下文物件,可以存放使用者的資訊,因此當放進去後,之後可以透過該物件取得 userId,這樣就可以進行一些 CRUD 操作!

示範 UserController

1
2
3
4
5
6
7
8
@GetMapping(value = "/{id}")
public ResponseEntity<?> getOneUser(@PathVariable String id, Principal principal) {
String userId = principal.getName();
if (!id.equals(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", "get another user account is forbidden"));
}
return userService.getOneUser(id);
}

最後看一個示範,可以在參數綁定一個 Principal 物件,這個物件就是剛剛 usernamePasswordAuthenticationToken 不是放入 userId,它會將 userId 當成一個 username,並產生一個 Principal 物件,因此你透過 principal.getName () 就能得到 userId。

總結

前陣子都一直再用 Golang 寫 Backend,現在整理 Spring Boot 的筆記,我覺得… 真的是複雜滿多,但是因為 Spring 的設計是比較龐大的,有些內部的組件要先了解後才能去做更改。

尤其 Spring Security 又是比較複雜的安全框架,需要先理解其運作,才可以有辦法去 hack 並更改自定義的操作。

整個示範程式碼放在我的 GitHub 歡迎參考:https://github.com/KennyChenFight/jwt-spring-security