Spring Security 实战:手把手实现 Email + Password 自定义认证

250 阅读4分钟

Spring Security 实战:手把手实现 Email + Password 自定义认证

一、为什么要写这篇文章?

在企业项目中,使用邮箱或手机号作为登录方式是非常常见的需求,而 Spring Security 默认只支持基于用户名(username)的认证流程,难以直接满足灵活的业务场景。

本文将以“邮箱 + 密码”登录为例,带你从零实现一个完整的 Spring Security 自定义认证流程,涵盖以下核心能力:

  • 自定义登录接口(支持 JSON 请求体)

  • 自定义认证令牌(AuthenticationToken)

  • 自定义认证逻辑(AuthenticationProvider)

  • 自定义过滤器(AuthenticationFilter)

  • 注册配置到 Spring Security 体系中

该项目基于:

  • JDK 17

  • Spring Boot 2.7.18

  • Spring Security 5.7.x

完整示例项目地址:

👉 gitee.com/wangwei5211…

二、项目结构与模块说明

spring-security-basic/
├── config/         ← Security 配置类 + 认证处理器
├── controller/     ← 测试接口,验证权限生效
├── filter/         ← 自定义认证过滤器(接收 email + password)
├── provider/       ← 自定义认证逻辑 Provider
├── token/          ← 自定义 AuthenticationToken
├── exception/      ← 自定义异常(如认证失败)
└── Application.java

三、核心实现拆解

1. 自定义认证 Token

我们自定义一个 EmailPasswordAuthenticationToken 来承载邮箱 + 密码:

public class EmailPasswordAuthenticationToken extends AbstractAuthenticationToken {

    @Serial
    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private final Object principal;

    private Object credentials;

    public EmailPasswordAuthenticationToken(Object principal, Object credentials) {
        super(null);
        this.principal = principal;
        this.credentials = credentials;
        setAuthenticated(false);
    }

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

    public static EmailPasswordAuthenticationToken unauthenticated(Object principal, Object credentials) {
        return new EmailPasswordAuthenticationToken(principal, credentials);
    }

    public static EmailPasswordAuthenticationToken authenticated(Object principal, Object credentials,
                                                                 Collection<? extends GrantedAuthority> authorities) {
        return new EmailPasswordAuthenticationToken(principal, credentials, authorities);
    }

    @Override
    public Object getCredentials() {
        return this.credentials;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        Assert.isTrue(!isAuthenticated,
                "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
        this.credentials = null;
    }

}

2. 自定义认证 Provider

用于验证邮箱密码是否正确,并返回认证成功后的 Authentication 对象:

@Component
public class EmailPasswordAuthenticationProvider implements AuthenticationProvider {

    private boolean forcePrincipalAsString = false;

    private GrantedAuthoritiesMapper grantedAuthoritiesMapper = new NullAuthoritiesMapper();

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Assert.isInstanceOf(EmailPasswordAuthenticationToken.class, authentication,
                "EmailPasswordAuthenticationProvider.onlySupports Only EmailPasswordAuthenticationToken is supported");
        // 获取token中的用户信息
        EmailPasswordAuthenticationToken token = (EmailPasswordAuthenticationToken) authentication;
        String email = token.getPrincipal().toString();
        String password = token.getCredentials().toString();
        // TODO:这里应接入真实的用户服务
        UserDetails userDetails = loadUserByEmail(email);

        // 验证邮箱密码
        boolean equals = userDetails.getPassword().equals(password);
        if (!equals) {
            throw new EmailPasswordAuthenticationException("邮箱或者密码错误");
        }

        // 创建成功的Authentication对象
        Object principalToReturn = userDetails;
        if (this.forcePrincipalAsString) {
            principalToReturn = principalToReturn.toString();
        }
        return createSuccessAuthentication(principalToReturn, authentication, userDetails);
    }

    private UserDetails loadUserByEmail(String email) {

        return new User("testUserName", "tHXEilIZtd",
                true, true, true, true,
                this.grantedAuthoritiesMapper.mapAuthorities(List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))));
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return EmailPasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }

    protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) {
        EmailPasswordAuthenticationToken result = new EmailPasswordAuthenticationToken(principal, authentication.getCredentials(),
                this.grantedAuthoritiesMapper.mapAuthorities(user.getAuthorities()));
        result.setDetails(authentication.getDetails());
        return result;
    }

}

3. 自定义认证 Filter

用于解析请求体中的 email + password 并触发认证:

public class EmailPasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public static final String SPRING_SECURITY_FORM_EMAIL_KEY = "email";

    public static final String SPRING_SECURITY_FORM_VERIFICATION_CODE_KEY = "password";

    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/api/v1/auth/loginByEmailPassword", "POST");

    private String emailParameter = SPRING_SECURITY_FORM_EMAIL_KEY;

    private String passwordParameter = SPRING_SECURITY_FORM_VERIFICATION_CODE_KEY;

    private boolean postOnly = true;

    public EmailPasswordAuthenticationFilter() {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
    }

    public EmailPasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        if (this.postOnly && !"POST".equals(request.getMethod())) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        String email = obtainUsername(request);
        email = (email != null) ? email.trim() : "";
        String password = obtainPassword(request);
        password = (password != null) ? password : "";

        EmailPasswordAuthenticationToken authRequest = EmailPasswordAuthenticationToken.unauthenticated(email, password);

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    @Nullable
    protected String obtainPassword(HttpServletRequest request) {
        return request.getParameter(this.passwordParameter);
    }

    @Nullable
    protected String obtainUsername(HttpServletRequest request) {
        return request.getParameter(this.emailParameter);
    }

    protected void setDetails(HttpServletRequest request, EmailPasswordAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }

    public void setUsernameParameter(String emailParameter) {
        Assert.hasText(emailParameter, "Username parameter must not be empty or null");
        this.emailParameter = emailParameter;
    }

    public void setPasswordParameter(String passwordParameter) {
        Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
        this.passwordParameter = passwordParameter;
    }

    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public final String getUsernameParameter() {
        return this.emailParameter;
    }

    public final String getPasswordParameter() {
        return this.passwordParameter;
    }

}

四、Spring Security 配置

将自定义组件整合进 Spring Security:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception {
        http.authenticationManager(authenticationManager)
                .authorizeRequests()
                // 允许所有人访问的接口
                .antMatchers("/api/v1/public/**").permitAll()
                // 不拦截认证接口
                .antMatchers("/api/v1/auth/**").permitAll()
                // 拥有指定角色才能访问
                .antMatchers("/api/v1/admin/**").hasRole("ADMIN")
                .antMatchers("/api/v1/tester/**").hasRole("TESTER")
                // 需要登录才能访问
                .antMatchers("/api/v1/**").authenticated()
                .and()
                // csrf配置
                .csrf()
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .and()
                //  添加自定义的logout
                .logout()
                .logoutUrl("/api/v1/auth/logout")
                .logoutSuccessHandler(new JsonLogoutSuccessHandler())
                .and()
                // 自定义异常配置
                .exceptionHandling()
                .accessDeniedHandler(new JsonErrorMsgAccessDeniedHandler())
                .authenticationEntryPoint(new JsonErrorMsgAuthenticationEntryPoint())
                .and()
                .apply(new EmailPasswordAuthenticationConfig<>("/api/v1/auth/loginByEmailPassword"));
        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(EmailPasswordAuthenticationProvider emailPasswordProvider) {
        return new ProviderManager(List.of(emailPasswordProvider));
    }

}

五、测试接口验证

访问localhost:8080/doc.html

​编辑

登录接口:

email:随意

password: tHXEilIZtd

返回登录成功信息或错误提示。

验证接口

/api/v1/test        →  需要登录才能访问的接口
/api/v1/public/test →  允许所有人访问的接口
/api/v1/admin/test  →  有ADMIN角色的用户才能访问的接口
/api/v1/admin/test  →  有TESTER角色的用户才能访问的接口

可通过 swagger ui页面 测试接口鉴权是否生效。

六、总结与拓展

本篇文章完整实现了 Spring Security 下基于 Email + Password 的自定义认证流程。

后续你可以基于本项目扩展:

  • 支持短信验证码登录(可自定义新的 Token + Provider)

  • 登录成功后签发 JWT 令牌

  • 接入数据库进行真实用户验证

  • 动态权限点管理体系(juejin.cn/post/751975…

欢迎收藏、点赞、关注后续实战系列文章!