上次我們解決了資料庫的設計,而這次文章我們需要解決兩大問題:
- Security
- Session
Web專案中,Security會有哪些問題?
首先,再次強調,該專案是採用前後端分離,而API的格式也盡量符合Restful API的格式。以下是我覺得最直接會遇到的安全問題:
- 存取API的權限控制
專案裡面有專屬於老師、助教、學生、管理員的各自API,很明顯不同身分的使用者不能存取其他身分的API,不然可能會造成學生身分卻能竄改自己的成績分數等問題。
- 還沒登入就進行API的存取
同樣的,假設該使用者尚未登入,就存取了API,也是不允許的,同時這樣的操作,也會讓系統無法判斷該使用者是誰,無法給予相對應的資料。
- 使用者密碼加密的問題
這個可以說是關於資料庫安全的問題,一般來說,使用者的密碼是不該以明碼的方式來存放資料庫,假設管理資料庫的人可以輕易看到每個使用者的明碼,那麼它就能夠透過你的帳戶進行登入,做一些操作,這是相當危險的事情,更別提假設資料庫被攻陷的話,後續引發的連鎖效應。
- SQL injection的問題
這個問題,基本上如果有使用後端框架裡面的ORM工具,就不會有這個問題,因為通常這個SQL injection是因為前端那部分塞一個SQL指令給後端,讓後端去運行,藉此來竄改資料庫的資料,但因為有ORM工具,通常都是直接拿變數的值去對應ORM的函數,所以通常不會有執行前端傳過來SQL指令的時候。
假設前端那邊傳來,account、password兩個參數,而這個學生就是這麼手賤,它把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的相關事項,首先按照需求,分別有authority、login*&、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需要定義,最主要就是getUsername、getPassword,Spring Security會根據這個去拿取這個使用者的account、password做一些身分驗證及登入的事項。
而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的存放位置後來我決定放在redis,redis是一個算是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功能做準備。