Spring Security的额外登录字段

2023/05/17

1. 概述

在本文中,我们将通过在标准登录表单中添加一个额外的字段来使用Spring Security实现自定义身份验证场景。

重点介绍两种不同的方法,以展示框架的多功能性和灵活的使用方法。

第一种方法是一个简单的解决方案,它侧重于重用现有的核心Spring Security实现。

第二种方法是一种更为自定义的解决方案,可能更适合高级用例。

2. Maven依赖

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.6.1</version>
    <relativePath/>
</parent>
 
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
     </dependency>
    <dependency>
        <groupId>org.thymeleaf.extras</groupId>
        <artifactId>thymeleaf-extras-springsecurity5</artifactId>
    </dependency>
</dependencies>

3. 项目配置

在我们的第一种方法中,我们重点关注重用Spring Security提供的实现。 特别是,我们将重用DaoAuthenticationProvider和UsernamePasswordToken,因为它们的“开箱即用”的。

主要组成部分包括:

  • SimpleAuthenticationFilter – UsernamePasswordAuthenticationFilter的子类。
  • SimpleUserDetailsService – UserDetailsService的实现。
  • User – Spring Security提供的User类的子类,用于声明我们的额外domain字段。
  • 声明安全规则并注入依赖bean - SecurityConfig 我们的Spring,Security配置类,将SimpleAuthenticationFilter插入过滤器链。
  • login.html – 一个收集username、password和domain的登录页面。

3.1 SimpleAuthenticationFilter

在我们的SimpleAuthenticationFilter中,domain和username字段是从HttpServletRequest对象中获取的。 我们拼接这些值并使用它们来创建UsernamePasswordAuthenticationToken的实例。

然后将token传递给AuthenticationProvider进行身份验证:

public class SimpleAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    public static final String SPRING_SECURITY_FORM_DOMAIN_KEY = "domain";

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }

        UsernamePasswordAuthenticationToken authRequest = getAuthRequest(request);
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    private UsernamePasswordAuthenticationToken getAuthRequest(HttpServletRequest request) {
        String username = obtainUsername(request);
        String password = obtainPassword(request);
        String domain = obtainDomain(request);

        if (username == null) {
            username = "";
        }
        if (password == null) {
            password = "";
        }
        if (domain == null) {
            domain = "";
        }

        String usernameDomain = String.format("%s%s%s", username.trim(), String.valueOf(Character.LINE_SEPARATOR), domain);
        return new UsernamePasswordAuthenticationToken(usernameDomain, password);
    }

    private String obtainDomain(HttpServletRequest request) {
        return request.getParameter(SPRING_SECURITY_FORM_DOMAIN_KEY);
    }
}

3.2 SimpleUserDetailsService

UserDetailsService定义了一个名为loadUserByUsername的方法。 我们的实现获取username和domain,然后将这些值传递给我们的UserRepository以获取User:


@Service("userDetailsService")
public class SimpleUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public SimpleUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String[] usernameAndDomain = StringUtils.split(username, String.valueOf(Character.LINE_SEPARATOR));
        if (usernameAndDomain == null || usernameAndDomain.length != 2) {
            throw new UsernameNotFoundException("Username and domain must be provided");
        }
        User user = userRepository.findUser(usernameAndDomain[0], usernameAndDomain[1]);
        if (user == null) {
            throw new UsernameNotFoundException(
                    String.format("Username not found for domain, username=%s, domain=%s", usernameAndDomain[0], usernameAndDomain[1]));
        }
        return user;
    }
}

3.3 Spring Security配置

我们的配置与标准Spring Security配置不同,因为我们通过调用addFilterBefore将SimpleAuthenticationFilter插入到默认值之前的过滤器链中:

public class SecurityConfig extends AbstractHttpConfigurer<SecurityConfig, HttpSecurity> {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
        http.addFilterBefore(authenticationFilter(authenticationManager), UsernamePasswordAuthenticationFilter.class);
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authProvider());
    }
}

我们能够使用提供的DaoAuthenticationProvider,因为我们使用SimpleUserDetailsService对其进行了配置。 回想一下,我们的SimpleUserDetailsService知道如何解析出我们的username和domain字段,并返回适当的用户以在身份验证时使用:

public class SecurityConfig extends AbstractHttpConfigurer<SecurityConfig, HttpSecurity> {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    public AuthenticationProvider authProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder);
        return provider;
    }
}

由于我们使用的是SimpleAuthenticationFilter,因此我们配置了自己的AuthenticationFailureHandler,以确保正确处理失败的登录尝试:

public class SecurityConfig extends AbstractHttpConfigurer<SecurityConfig, HttpSecurity> {

    public SimpleAuthenticationFilter authenticationFilter(AuthenticationManager authenticationManager) throws Exception {
        SimpleAuthenticationFilter filter = new SimpleAuthenticationFilter();
        filter.setAuthenticationManager(authenticationManager);
        filter.setAuthenticationFailureHandler(failureHandler());
        return filter;
    }

    public SimpleUrlAuthenticationFailureHandler failureHandler() {
        return new SimpleUrlAuthenticationFailureHandler("/login?error=true");
    }
}

3.4 登录页面

我们使用的登录页面用于提交由SimpleAuthenticationFilter获取的额外domain字段:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
    <title>Login page</title>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="">
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
    <link href="http://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/>
    <link rel="stylesheet" href="/css/main.css" th:href="@{/css/main.css}"/>
</head>
<body>
<div class="container">
    <form class="form-signin" th:action="@{/login}" method="post">
        <h2 class="form-signin-heading">Please sign in</h2>
        <p>Example: user / domain / password</p>
        <p th:if="${param.error}" class="error">Invalid user, password, or domain</p>
        <p>
            <label for="username" class="sr-only">Username</label>
            <input type="text" id="username" name="username" class="form-control" placeholder="Username" required
                   autofocus/>
        </p>
        <p>
            <label for="domain" class="sr-only">Domain</label>
            <input type="text" id="domain" name="domain" class="form-control" placeholder="Domain" required autofocus/>
        </p>
        <p>
            <label for="password" class="sr-only">Password</label>
            <input type="password" id="password" name="password" class="form-control" placeholder="Password" required
                   autofocus/>
        </p>
        <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
        <br/>
        <p><a href="/index" th:href="@{/index}">Back to home page</a></p>
    </form>
</div>
</body>
</html>

当我们运行应用程序并访问http://localhost:8081时,我们会看到一个访问受保护页面的链接, 单击该链接将显示登录页面。正如预期的那样,我们可以看到额外domain字段:

3.5 概括

在我们的第一个示例中,通过“伪造”username字段,我们能够重用DaoAuthenticationProvider和UsernamePasswordAuthenticationToken。

因此,我们能够以最少的配置和代码添加对额外字段的支持。

4. 自定义项目配置

我们的第二种方法与第一种方法非常相似,但可能更适合场景特定的用例。

其关键组成部分包括:

  • CustomAuthenticationFilter – UsernamePasswordAuthenticationFilter的子类。
  • CustomUserDetailsService – 声明loadUserByUsernameAndDomain方法的自定义接口。
  • CustomUserDetailsServiceImpl – CustomUserDetailsService的实现。
  • CustomUserDetailsAuthenticationProvider – AbstractUserDetailsAuthenticationProvider的子类。
  • CustomAuthenticationToken – UsernamePasswordAuthenticationToken的子类。
  • User – Spring Security提供的User类的子类,它声明了我们的额外domain字段。
  • SecurityConfig - Spring Security配置类,将CustomAuthenticationFilter插入过滤器链,声明安全规则并注入依赖bean。
  • login.html – 收集username、password和domain的登录页面。

4.1 CustomAuthenticationFilter

在我们的CustomAuthenticationFilter中,我们从请求中获取username、password和domain字段。 这些值用于创建CustomAuthenticationToken的实例,该实例被传递给AuthenticationProvider进行身份验证:

public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    public static final String SPRING_SECURITY_FORM_DOMAIN_KEY = "domain";

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }

        CustomAuthenticationToken authRequest = getAuthRequest(request);
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    private CustomAuthenticationToken getAuthRequest(HttpServletRequest request) {
        String username = obtainUsername(request);
        String password = obtainPassword(request);
        String domain = obtainDomain(request);

        if (username == null) {
            username = "";
        }
        if (password == null) {
            password = "";
        }
        if (domain == null) {
            domain = "";
        }

        return new CustomAuthenticationToken(username, password, domain);
    }

    private String obtainDomain(HttpServletRequest request) {
        return request.getParameter(SPRING_SECURITY_FORM_DOMAIN_KEY);
    }
}

4.2 CustomUserDetailsService

我们自定义的CustomUserDetailsService接口定义了一个名为loadUserByUsernameAndDomain的方法。

其实现类CustomUserDetailsServiceImpl简单地实现该方法并委托给我们的CustomUserRepository以获取User:


@Service("userDetailsService")
public class CustomUserDetailsServiceImpl implements CustomUserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsernameAndDomain(String username, String domain) throws UsernameNotFoundException {
        if (StringUtils.isAnyBlank(username, domain)) {
            throw new UsernameNotFoundException("Username and domain must be provided");
        }
        User user = userRepository.findUser(username, domain);
        if (user == null) {
            throw new UsernameNotFoundException(String.format("Username not found for domain, username=%s, domain=%s", username, domain));
        }
        return user;
    }
}

@Repository("userRepository")
public class CustomUserRepository implements UserRepository {

    private final PasswordEncoder passwordEncoder;

    public CustomUserRepository(PasswordEncoder passwordEncoder) {
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public User findUser(String username, String domain) {
        if (StringUtils.isAnyBlank(username, domain)) {
            return null;
        } else {
            Collection<? extends GrantedAuthority> authorities = new ArrayList<>();
            return new User(username, domain, passwordEncoder.encode("secret"),
                    true, true, true, true, authorities);
        }
    }
}

4.3 CustomUserDetailsAuthenticationProvider

我们的CustomUserDetailsAuthenticationProvider扩展了AbstractUserDetailsAuthenticationProvider,并委托给我们的CustomUserDetailService来检索User。 这个类最重要的功能是retrieveUser方法的实现。

请注意,我们必须将AuthenticationToken强转为CustomAuthenticationToken才能访问我们的自定义domain字段:

public class CustomUserDetailsAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

    // ...

    private final CustomUserDetailsService userDetailsService;

    public CustomUserDetailsAuthenticationProvider(PasswordEncoder passwordEncoder, CustomUserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        CustomAuthenticationToken auth = (CustomAuthenticationToken) authentication;
        UserDetails loadedUser;

        try {
            loadedUser = this.userDetailsService.loadUserByUsernameAndDomain(auth.getPrincipal().toString(), auth.getDomain());
        } catch (UsernameNotFoundException notFound) {
            if (authentication.getCredentials() != null) {
                String presentedPassword = authentication.getCredentials().toString();
                passwordEncoder.matches(presentedPassword, userNotFoundEncodedPassword);
            }
            throw notFound;
        } catch (Exception repositoryProblem) {
            throw new InternalAuthenticationServiceException(repositoryProblem.getMessage(), repositoryProblem);
        }

        if (loadedUser == null) {
            throw new InternalAuthenticationServiceException("UserDetailsService returned null, " + "which is an interface contract violation");
        }
        return loadedUser;
    }
}

4.4 CustomAuthenticationToken

对于CustomAuthenticationToken,我们只是简单的继承UsernamePasswordAuthenticationToken,并添加一个额外的字段:

public class CustomAuthenticationToken extends UsernamePasswordAuthenticationToken {

    private final String domain;

    public CustomAuthenticationToken(Object principal, Object credentials, String domain) {
        super(principal, credentials);
        this.domain = domain;
        super.setAuthenticated(false);
    }

    public CustomAuthenticationToken(Object principal, Object credentials, String domain, Collection<? extends GrantedAuthority> authorities) {
        super(principal, credentials, authorities);
        this.domain = domain;
        super.setAuthenticated(true); // must use super, as we override
    }

    public String getDomain() {
        return this.domain;
    }
}

4.5 概括

第二种方法与之前介绍的简单方法几乎相同, 通过实现我们自己的AuthenticationProvider和CustomAuthenticationToken,我们避免了使用自定义解析逻辑调整我们的username字段。

5. 总结

在本文中,我们在Spring Security中实现了一个登录表单,它包含了一个额外的domain字段。我们以两种不同的方式实现了这一点:

  • 在简单方法中,我们最大限度地减少了需要编写的代码量。 通过使用自定义解析逻辑调整username,我们能够重用DaoAuthenticationProvider和UsernamePasswordAuthentication
  • 在更加自定义的方法中,我们通过扩展AbstractUserDetailsAuthenticationProvider并为我们自己的CustomUserDetailsService 提供CustomAuthenticationToken来提供自定义字段支持。

与往常一样,本教程的完整源代码可在GitHub上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章