UserDetailsService


目前我們的 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 {
}

SpringInitializergetRootConfigClasses 裏增加 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 方法,取得了編碼後的密碼,然後透過 HttpServletRequestlogin 方法進行登入,這個部份必須想辦法改用 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();  
    }
}

在這邊看到了 successHandlerfailureHandler 的運用,可以用來實作原本在 AccessController 中的部份流程,重新導向的話,基本上是要求瀏覽器重新請求指定的網址,這個不成問題,在登入的過程中,除了可以透過 request.getParameter("username") 取得使用者名稱之外,在 successHandler 的處理器中,也可以透過第三個參數,也就是 Authentication 實例的 getName 來取得。

比較需要注意的地方在於,你不能使用 HttpServletRequestgetRequestDispatcher 來進行 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 的驗證過程了,在透過 AuthenticationManagerBuilderinMemoryAuthenticationjdbcAuthentication 設定驗證來源時,會各自套用 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 中找到以上的範例專案。