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

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

--

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

  • Security
  • Session

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

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

  • 存取API的權限控制

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

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

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

  • 使用者密碼加密的問題

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

  • SQL injection的問題

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

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

DELETE FROM teacher

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

findByAccountAndPassword(account, password);

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

就像如下:

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,這些來解決我們以上談到的問題。

直接上程式碼:

@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();
}
}

程式碼解釋:

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

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

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

如下:

@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最主要做以下的事情:

@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類別,因此可直接回傳。

@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給刪掉。

可參考如下:

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

<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>

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

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

# 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功能做準備。

--

--