目前我們的 gossip 專案,是基於 Web 容器安全管理,而且有自己的密碼管理方式,想要直接套用先前談過的 Spring Security 的話,似乎得做些不少的修改,這也蠻符合既有專案想套用 Spring Security 的情況,有沒有辦法比較簡單的套用呢?
無論如何,先來做點基本的設定吧!先在 build.gradle 裏加上相依的程式庫:
compile 'org.springframework.security:spring-security-core:5.1.2.RELEASE'
compile 'org.springframework.security:spring-security-config:5.1.2.RELEASE'
compile 'org.springframework.security:spring-security-web:5.1.2.RELEASE'
新增 SecurityInitializer
:
package cc.openhome.web;
import org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer;
public class SecurityInitializer extends AbstractSecurityWebApplicationInitializer {
}
在 SpringInitializer
的 getRootConfigClasses
裏增加 SecurityConfig
:
package cc.openhome.web;
import org.springframework.web.servlet.support.*;
public class SpringInitializer
extends AbstractAnnotationConfigDispatcherServletInitializer{
... 略
@Override
protected Class<?>[] getRootConfigClasses() {
return new Class<?>[] {RootConfig.class, SecurityConfig.class};
}
... 略
}
然後就可以在 SecurityConfig
裏進行設定了,不過在這之前,你得先看看 web.xml 中有關於 Web 容器安全機制相關設定,瞭解哪些頁面需要防護,以及哪些角色可以存取,角色部份比較單純,因為 gossip 應用程式只有一種角色 ROLE_MEMBER
。
接著,得看看目前處理登入登出的 AccessController
在做些什麼:
package cc.openhome.controller;
...略
@Controller
public class AccessController {
...略
@Autowired
private UserService userService;
@PostMapping("login")
public String login(
@RequestParam String username,
@RequestParam String password,
HttpServletRequest request) {
Optional<String> optionalPasswd = userService.encryptedPassword(username, password);
try {
request.login(username, optionalPasswd.get());
request.getSession().setAttribute("login", username);
return REDIRECT_MEMBER_PATH;
} catch(NoSuchElementException | ServletException e) {
request.setAttribute("errors", Arrays.asList("登入失敗"));
List<Message> newest = userService.newestMessages(10);
request.setAttribute("newest", newest);
return INDEX_PATH;
}
}
@GetMapping("logout")
public String logout(HttpServletRequest request) throws ServletException {
request.logout();
return REDIRECT_INDEX_PATH;
}
}
UserService
透過 encryptedPassword
方法,取得了編碼後的密碼,然後透過 HttpServletRequest
的 login
方法進行登入,這個部份必須想辦法改用 Spring Security 的流程,而 request.logout()
登出的部份也是。
在登入之後,必須設定 login
屬性,以便應用程式其他地方使用,接著導向會員頁面;登入失敗則必須顯示登入失敗的訊息並導向首頁。
然後,根據這些資訊,可以初步在 SecurityConfig
中如下設定:
package cc.openhome.web;
...略
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/member", "/new_message", "/del_message", "/logout").hasRole("MEMBER")
.anyRequest().permitAll()
.and()
.formLogin().loginPage("/").loginProcessingUrl("/login")
.successHandler((request, response, auth) -> {
request.getSession().setAttribute("login", auth.getName());
response.sendRedirect("/gossip/member");
})
.failureHandler((request, response, ex) -> {
response.sendRedirect("/gossip?username=" + request.getParameter("username") + "&error");
})
.and()
.logout().logoutUrl("/logout")
.addLogoutHandler((request, response, auth) -> {
request.getSession().invalidate();
try {
response.sendRedirect("/gossip");
} catch (IOException e) {
throw new UncheckedIOException(e);
}
})
.and()
.csrf().disable();
}
}
在這邊看到了 successHandler
與 failureHandler
的運用,可以用來實作原本在 AccessController
中的部份流程,重新導向的話,基本上是要求瀏覽器重新請求指定的網址,這個不成問題,在登入的過程中,除了可以透過 request.getParameter("username")
取得使用者名稱之外,在 successHandler
的處理器中,也可以透過第三個參數,也就是 Authentication
實例的 getName
來取得。
比較需要注意的地方在於,你不能使用 HttpServletRequest
的 getRequestDispatcher
來進行 forward
來轉發,getRequestDispatcher
要嘛是個 JSP,要嘛是個 Servlet,別忘了,Spring MVC 是透過 DispatcherServlet
來處理請求,想想看目前的 gossip 應用程式中,你 getRequestDispatcher
時的對象究竟要設誰呢?
因此在這邊,仿效〈自訂登入登出頁面〉中的做法,在登入失敗時給予一個 error
參數並重新導向,為此,必須改一下 index.html 中顯示「登入失敗」的模版片段:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Gossip 微網誌</title>
<link rel="stylesheet" href="css/gossip.css" type="text/css">
</head>
<body>
<div id="login">
...略
<p><span th:unless="${param.error == null}" th:text='登入失敗' style='color: rgb(255, 0, 0);'>登入失敗</span></p>
<form method='post' action='login'>
...略
</form>
</div>
...略
</body>
</html>
因為 gossip 應用程式在登入時,並沒有使用 CSRF Token,在這邊使用了 csrf().disable()
停用 CSRF 防護。
現在的問題剩下,怎麼實作驗證與授權了,暫時不想大改應用程式的話,一個方式就是仍然使用 userService.encryptedPassword(username, password)
,想辦法在不修改應用程式既有程式碼的情況下,也能提供使用者名稱、密碼、角色清單資訊。
這就可以進一步來認識 Spring Security 的驗證過程了,在透過 AuthenticationManagerBuilder
的 inMemoryAuthentication
或 jdbcAuthentication
設定驗證來源時,會各自套用 UserDetailsManagerConfigurer
的子類實例,真正在進行時驗證,就會從 UserDetailsManagerConfigurer
取得 UserDetailsService
實例,以便從驗證來源取得使用者名稱、密碼、角色清單等資訊。
UserDetailsService
,它只有一個方法必須實作:
package org.springframework.security.core.userdetails;
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
也就是說根據使用者名稱,想辦法提供 UserDetails
實例,UserDetails
包含了使用者名稱、密碼、角色清單等資訊,Spring Security 提供了一個簡單的 User
實作,在建構時可以只提供使用者名稱、密碼、角色清單。
如果有從驗證來源取得使用者名稱、密碼、角色清單等資訊時有各自的策略,也可以自行實作 UserDetailsService
,既然如此,這不就符合我們的需求嗎?
package cc.openhome.web;
...
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AccountDAO accountDAO;
@Autowired
private UserService userService;
... 略
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(username -> {
return new User(
username,
username + ":" + accountDAO.accountByUsername(username).get().getPassword(),
Arrays.asList(new SimpleGrantedAuthority("ROLE_MEMBER"))
);
})
.passwordEncoder(new UserServiceBasedPasswordEncoder());
}
private class UserServiceBasedPasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
// 不用編碼,因為後續實際上會使用 UserService 的 encryptedPassword 方法
return rawPassword.toString();
}
@Override
public boolean matches(CharSequence rawPassword, String passwordFromUserDetails) {
String[] namePassword = passwordFromUserDetails.split(":");
String name = namePassword[0];
String password = namePassword[1];
return userService.encryptedPassword(name, rawPassword.toString()).get().equals(password);
}
}
}
AuthenticationManagerBuilder
可以透過 userDetailsService
設定 UserDetailsService
實例,因為 UserDetailsService
只有一個方法要實作,這邊直接用了 Lambda 語法來實現,在當中直接使用 gossip 專案中既有的 AccountDAO
實作,因為它正好也有個 accountByUsername
方法,可以根據名稱取得使用者資訊。
UserDetails
實作中必須包含角色清單,在這邊使用 SimpleGrantedAuthority
來建立,可作為 User
建構時的第三個參數。
因為我們會透過 userService.encryptedPassword(username, password)
來編碼,這可以實作為 PasswordEncoder
,如註解中看到的,encode
的部份不用實作,一個比較 workaround 的地方在於,userService.encryptedPassword(username, password)
需要使用者名稱,然而,matches
方法中沒有提供,因此,在實作 UserDetailsService
,故意在查詢出來的密碼前,附加了使用者名稱與 ":"
。
UserDetails
中提供的密碼,會成為 matches
的第二個引數,只要依 ":"
切割,就可以取得使用者名稱的部份,因而可以拿來給 userService.encryptedPassword(username, password)
使用。
這麼一來,就可以刪除 web.xml 中與 Web 安全機制相關的設定,並將 AccessController
刪除,重新部署應用程式之後,就會由 Spring Security 接手頁面防護、驗證與授權等任務了。
你可以在 gossip 中找到以上的範例專案。