PenguinCoCo 後端專案重構之旅 - Security 和 Session 的處理

上次我們解決了資料庫的設計,而這次文章我們需要解決兩大問題:

  • Security
  • Session

Web 專案中,Security 會有哪些問題?

首先,再次強調,該專案是採用前後端分離,而 API 的格式也盡量符合 Restful API 的格式。以下是我覺得最直接會遇到的安全問題:

  • 存取 API 的權限控制

    專案裡面有專屬於老師、助教、學生、管理員的各自 API,很明顯不同身分的使用者不能存取其他身分的 API,不然可能會造成學生身分卻能竄改自己的成績分數等問題。

  • 還沒登入就進行 API 的存取

    同樣的,假設該使用者尚未登入,就存取了 API,也是不允許的,同時這樣的操作,也會讓系統無法判斷該使用者是誰,無法給予相對應的資料。

  • 使用者密碼加密的問題

    這個可以說是關於資料庫安全的問題,一般來說,使用者的密碼是不該以明碼的方式來存放資料庫,假設管理資料庫的人可以輕易看到每個使用者的明碼,那麼它就能夠透過你的帳戶進行登入,做一些操作,這是相當危險的事情,更別提假設資料庫被攻陷的話,後續引發的連鎖效應。

  • SQL injection 的問題

    這個問題,基本上如果有使用後端框架裡面的 ORM 工具,就不會有這個問題,因為通常這個 SQL injection 是因為前端那部分塞一個 SQL 指令給後端,讓後端去運行,藉此來竄改資料庫的資料,但因為有 ORM 工具,通常都是直接拿變數的值去對應 ORM 的函數,所以通常不會有執行前端傳過來 SQL 指令的時候。

    假設前端那邊傳來,accountpassword 兩個參數,而這個學生就是這麼手賤,它把 account 輸入了一個 SQL 指令:

    1
    DELETE FROM teacher

    但是 ORM 這邊執行的函數接收方式是這樣:

    1
    findByAccountAndPassword(account, password);

    而實際上 ORM 框架會將 findByAccountAndPassword 再轉成 SQL 指令,但只會把 account、password 變數參考進去,而不會執行該變數裡面的指令。

    就像如下:

    1
    SELECT * FROM teacher where account='DELETE FROM teacher' AND password='xxxx'

    實際上就只是把前端傳過來的 SQL 指令當作一個字串變數值而已,資料庫這並不會執行到,自然也不會刪除掉 teacher 這個表格囉。

Web 專案中,Session 會有哪些問題?

雖然專案是採 Restful API 的方式,但這邊我還是選擇採用 Session 的方案,而不是採用 JWT 來進行驗證使用者的功能。原因如下:

  • 目前專案只是純網站,並不是還有分 APP 端,因為如果有 APP 端,可能會變成 API 這邊要統一,並且由 Web 及 APP 端存取相同介面的 API,藉此來統一,加上 APP 端這邊並沒有所謂的 Session 的方式,那我就會採用 JWT 的方式。
  • Session 紀錄登入後的資訊方便,且容易掌握當下的使用者人數,通常使用者登入後,我會在 session 端存放使用者的帳號,這樣在一些的 API 上,前端就不用再刻意傳學生的帳號的參數,讓後端辨別是要存取哪位學生的資料。當然前端這邊就不用再找空間來存放這樣的資訊。由後端的 session 去存放這樣的資訊,再來,因為 session 是可以主動被後端刪除的,因此做一些強迫登出的動作也方便。相較於 JWT 就比較屬於被動性。

但是 Session 還是會有一些問題需要去處理:

  • Session 存放位置

    原本的專案,Session 其實是存放在內存中,但這樣會有一個問題,因為現在為了使用者體驗,我會將 session 過期時間設為無限,讓使用者不需要有頻繁登入的操作,登出的時間由使用者自由掌握。但這樣會導致內存中的 session 過多,因為內存的消耗是有限的,理論上內存越來越多的情況下,會導致後端運行速度變慢。

  • 不同瀏覽器的登入操作,造成同個使用者的 Session 變多,而不是唯一

    這個原因,主要是如果使用者採用不同瀏覽器進行登入的話,產生的 Session 其實是不一樣的,但都是同一位使用者,可能會導致同個使用者的 session 越存越多。

Security 的解決方案

這邊的解決方案,我都是參考我之前撰寫的一篇文章,所以在這邊就不會講太細了,有興趣的朋友可以參考這篇文章:

Spring-Security - 結合 RestfulApi 的設計

我們這邊採用的就是 Spring Security,它可以說包辦了許多 Spring 中所有 Security 的設定,並且我們可以根據自己的需求去客製化裡面的設定,但這個框架是非常雜的,所以需要花一點時間去了解。

先來談談 package 裡面的配置;

這個可以說是專案的設定,所以我習慣根 package 命名為 config,並且新增一個 security 的 package,而這個 package 裡面就是專門設定 security 的相關事項,首先按照需求,分別有 authoritylogin&*、logout,這些來解決我們以上談到的問題。

直接上程式碼:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

private SecurityService securityService;

@Autowired
public SecurityConfig(SecurityService securityService) {
this.securityService = securityService;
}

/*
設定使用者權限驗證的service(連結DB)、使用者密碼加密
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(securityService)
.passwordEncoder(new BCryptPasswordEncoder());
}

@Override
protected void configure(HttpSecurity http) throws Exception {
/*
未登入存取API控制及身分權限存取API控制
*/
http.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPointImpl())
.accessDeniedHandler(new AccessDeniedHandlerImpl());
/*
login API控制
*/
http.addFilterAt(loginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
/*
logout API控制
*/
http.logout()
.logoutUrl("/api/logout")
.invalidateHttpSession(true)
.logoutSuccessHandler(new LogoutSuccessHandlerImpl())
.and()
.csrf()
.disable();
}

/*
設定Login API,登入成功及失敗的回覆
*/
@Bean
LoginAuthenticationFilter loginAuthenticationFilter() throws Exception {
LoginAuthenticationFilter filter = new LoginAuthenticationFilter();
filter.setAuthenticationManager(authenticationManagerBean());
filter.setAuthenticationSuccessHandler(new LoginSuccessHandlerImpl());
filter.setAuthenticationFailureHandler(new LoginFailureHandlerImpl());
filter.setFilterProcessesUrl("/api/login");
return filter;
}

/*
取得預設的authenticationManagerBean,用來給LoginAuthenticationFilter設定
*/
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}

程式碼解釋:

1
2
3
4
5
6
7
8
/*
設定使用者權限驗證的service(連結DB)、使用者密碼加密
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(securityService)
.passwordEncoder(new BCryptPasswordEncoder());
}

這邊就是要讓 spring security 能夠去資料庫拿取使用者的帳號密碼,因此這邊如果要客製化的話,就是客製去資料庫拿帳號密碼這段,也就是 securityService。再來,需要設定使用者密碼的加密演算法,這邊的演算法要跟註冊使用者那邊的加密演算法要一樣,這樣 Spring Security 才能根據這個演算法去跟前端傳過來的明碼進行比對,才知道是不是正確的密碼。

要建立 securityService 類別前,要先讓使用者的實體類別先去 implements UserDetails 類別

如下:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@EqualsAndHashCode(callSuper = true)
@Entity
@Data
@NoArgsConstructor
public class Student extends AbstractUser implements UserDetails {

private String studentClass;
@ManyToMany
@JoinTable(name = "student_course", joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id"))
private List<Course> courses;
@OneToMany(mappedBy = "bestStudent", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Problem> bestProblems;
@OneToMany(mappedBy = "student", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Judge> judges;
@OneToMany(mappedBy = "referencedStudent", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Copy> referencedCopies;
@OneToMany(mappedBy = "referenceStudent", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Copy> referenceCopies;
@OneToMany(mappedBy = "student", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Feedback> feedbacks;

public Student(String account, String password,
String name, String studentClass) {
super(account, password, name);
this.studentClass = studentClass;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_student"));
return authorities;
}

@Override
public String getUsername() {
return super.getAccount();
}

@Override
public String getPassword() {
return super.getPassword();
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}

}

那麼就會有一些 Override 的 method 需要定義,最主要就是 getUsernamegetPassword,Spring Security 會根據這個去拿取這個使用者的 accountpassword 做一些身分驗證及登入的事項。

而 securityService 最主要做以下的事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public UserDetails loadUserByUsername(String account) throws UsernameNotFoundException {
Optional<Student> studentOptional = studentRepository.findByAccount(account);
Optional<Teacher> teacherOptional = teacherRepository.findByAccount(account);
Optional<Assistant> assistantOptional = assistantRepository.findByAccount(account);
Optional<Admin> adminOptional = adminRepository.findByAccount(account);

if (studentOptional.isPresent()) {
return studentOptional.get();
}
else if (teacherOptional.isPresent()) {
return teacherOptional.get();
}
else if (assistantOptional.isPresent()) {
return assistantOptional.get();
}
else if (adminOptional.isPresent()) {
return adminOptional.get();
}
else {
throw new UsernameNotFoundException("account not found");
}
}

根據 account 去不同的使用者身分的 table 拿取資料,因為這邊 account 是不會與其他身分的使用者衝突的。因此只會有一種存在的可能。

如果資料存在,則回傳 UserDetails 物件,而使用者的實體因為已經 implements UserDetails 類別,因此可直接回傳。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
protected void configure(HttpSecurity http) throws Exception {
/*
未登入存取API控制及身分權限存取API控制
*/
http.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPointImpl())
.accessDeniedHandler(new AccessDeniedHandlerImpl());
/*
login API控制
*/
http.addFilterAt(loginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
/*
logout API控制
*/
http.logout()
.logoutUrl("/api/logout")
.invalidateHttpSession(true)
.logoutSuccessHandler(new LogoutSuccessHandlerImpl())
.and()
.csrf()
.disable();
}

這邊的話就是設定未登入存取 API 控制、身分權限存取 API 控制、login API、logout API。要注意的就是 login 及 logout 這邊都要特別去對 session 做出來。我這邊就是登入後會在 session 存放使用者的 account,登出就會把 session 給刪掉。

可參考如下:

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
public class LoginSuccessHandlerImpl implements AuthenticationSuccessHandler {

private final String LOGGED_IN = "logged_in";

@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
Authentication authentication) throws IOException {
String account = authentication.getName();
Collection authorityCollection = authentication.getAuthorities();
String authority = authorityCollection.iterator().next().toString().replace("ROLE_", "");

HttpSession session = httpServletRequest.getSession();
session.setAttribute(LOGGED_IN, account);

ObjectMapper mapper = new ObjectMapper();
Map<String, String> result = new HashMap<>();
result.put("authority", authority);

httpServletResponse.setContentType("application/json;charset=UTF-8");
httpServletResponse.setStatus(HttpServletResponse.SC_OK);
httpServletResponse.getWriter().write(mapper.writeValueAsString(result));
httpServletResponse.getWriter().flush();
httpServletResponse.getWriter().close();
}
}

登入成功後,會在 session 的屬性中存放使用者的 account,方便之後 API 的存取。

Session 的解決方案

首先關於 Session 的存放位置後來我決定放在 redisredis 是一個算是 NoSQL 的資料庫,但是它讀入存取的性能相當優秀,通常都會拿來當作快取的資料庫!再來,放在資料庫有一個好處,那就是我可以透過資料庫的確切得知目前 Session 有多少,根據 Session 我也可以知道目前系統的登入人數。

相較於之前存在內存空間,我認為這樣的作法彈性反而更大。再來關於多個瀏覽器的登入問題,我目前是不打算做甚麼約束,也就是說雖然不同瀏覽器的登入會造成同個使用者的 Session 存放數量變多,但我目前的做法是還不先刪除同樣使用者的 Session,為了保持使用者體驗,也有之後在未來會採用 Session 過期的策略來清理 Session。

怎麼用 redis 來存放 Session 呢?其實很簡單,只要加入以下 dependency

1
2
3
4
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>

因為在 Springboot 裡面,它會默認 Session 是存在內存,但只要提供該 dependency,在自動配置下,就會幫我們自動存入 redis 裡面。

因此只要我們在 aplication.properties 裡面設定 redis 的連線資訊就可以了:

1
2
3
4
5
6
# Session and Redis 設定
spring.session.store-type=redis
server.servlet.session.timeout=-1s
spring.redis.host=localhost
spring.redis.password=root
spring.redis.port=6379

同時這邊也設定 Session 的過期時間-1s 就代表是無限期存活 Session。

基本上,這樣就全部設定完了,剛剛在 LoginSuccessHandlerImpl 這裡面,當我們登入成功後,會在 session 裡面的屬性存放 account,基本上做這樣的動作,就會自動存入 redis 資料庫。

畫面如下:

預設的 session 存放路徑如上面這樣。基本上就是在 sessions 這裡面會存放所有的 session,而每個 sessionId 當作 key,value 就是你存放的屬性內容。

總結

做到這一步之後,基本的 Security 跟 Session 就算完成了,日後再根據需求做更改。下篇文章講解系統分解架構,幫助之後撰寫 API 功能做準備。

最後最後!請聽我一言!

如果你還沒有註冊 Like Coin,你可以在文章最下方看到 Like 的按鈕,點下去後即可申請帳號,透過申請帳號後可以幫我的文章按下 Like,而 Like 最多可以點五次,而你不用付出任何一塊錢,就能給我寫這篇文章的最大的回饋!