Практичная галантерея Spring Security: войдите с нуля с кодом подтверждения

Spring Boot Java

1. Введение

предыдущий оSpring SecurityНаписал две статьи, одна вводнаяUsernamePasswordAuthenticationFilter, другой - введениеAuthenticationManager. Многие студенты заявили, что не могут понять, как использовать эти две вещи, и какие практические проблемы можно решить? Итак, сегодня мы применим эти две теории на практике, напишем с нуля СМС код подтверждения для входа и адаптируем его кSpring Securityв системе. Если у вас есть какие-либо сомнения в чтении, вы можете вернуться и просмотреть эти две статьи, которые могут развеять многие сомнения.

Конечно, вы можете изменить его, чтобы войти в систему с кодом подтверждения вашей электронной почты или других устройств связи.

2. ЖИЗНЕННЫЙ ЦИКЛ CAPTCHA

Код подтверждения имеет срок действия, обычно 5 минут. Общая логика заключается в том, что пользователь вводит номер мобильного телефона для получения кода подтверждения, а сервер кэширует код подтверждения. Пользователь может использовать проверочный код для успешной проверки только один раз в течение максимального периода действия (чтобы не тратить проверочный код впустую); срок его действия истечет по истечении максимального времени.

Жизненный цикл кеша проверочного кода:

public interface CaptchaCacheStorage {

    /**
     * 验证码放入缓存.
     *
     * @param phone the phone
     * @return the string
     */
    String put(String phone);

    /**
     * 从缓存取验证码.
     *
     * @param phone the phone
     * @return the string
     */
    String get(String phone);

    /**
     * 验证码手动过期.
     *
     * @param phone the phone
     */
    void expire(String phone);
}

Обычно мы используем промежуточное программное обеспечение кэширования, такое какRedis,Ehcache,MemcachedПодождите, чтобы сделать это. Чтобы упростить использование различного промежуточного программного обеспечения учащимися, которые смотрят учебник. Здесь я объединяюSpring CacheКэш-обработка капчи намеренно абстрагируется.

private static final String SMS_CAPTCHA_CACHE = "captcha";
@Bean
CaptchaCacheStorage captchaCacheStorage() {
    return new CaptchaCacheStorage() {

        @CachePut(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
        @Override
        public String put(String phone) {
            return RandomUtil.randomNumbers(5);
        }

        @Cacheable(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
        @Override
        public String get(String phone) {
            return null;
        }

        @CacheEvict(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
        @Override
        public void expire(String phone) {

        }
    };
}

Важно обеспечить надежность кэша, что тесно связано с опытом пользователя.

Затем мы должны написать службу проверки кода, а код обслуживания имеет два основных функция:Отправить код подтвержденияикод верификации. Другие, такие как статистика, черные списки и исторические записи, могут быть настроены в соответствии с реальным бизнесом. Здесь реализованы только основные функции.

/**
 * 验证码服务.
 * 两个功能: 发送和校验.
 *
 * @param captchaCacheStorage the captcha cache storage
 * @return the captcha service
 */
@Bean
public CaptchaService captchaService(CaptchaCacheStorage captchaCacheStorage) {
    return new CaptchaService() {
        @Override
        public boolean sendCaptcha(String phone) {
            String existed = captchaCacheStorage.get(phone);
            if (StringUtils.hasText(existed)) {
                // 节约成本的话如果缓存中有可用的验证码 不再发新的验证码
                log.warn("captcha code 【 {} 】 is available now", existed);
                return false;
            }
            // 生成验证码并放入缓存
            String captchaCode = captchaCacheStorage.put(phone);
            log.info("captcha: {}", captchaCode);

            //todo 这里自行完善调用第三方短信服务发送验证码
            return true;
        }

        @Override
        public boolean verifyCaptcha(String phone, String code) {
            String cacheCode = captchaCacheStorage.get(phone);

            if (Objects.equals(cacheCode, code)) {
                // 验证通过手动过期
                captchaCacheStorage.expire(phone);
                return true;
            }
            return false;
        }
    };
}

Далее, согласноCaptchaServiceНаписать интерфейс отправки SMS/captcha/{phone}.

@RestController
@RequestMapping("/captcha")
public class CaptchaController {

    @Resource
    CaptchaService captchaService;


    /**
     * 模拟手机号发送验证码.
     *
     * @param phone the mobile
     * @return the rest
     */
    @GetMapping("/{phone}")
    public Rest<?> captchaByMobile(@PathVariable String phone) {
        //todo 手机号 正则自行验证

        if (captchaService.sendCaptcha(phone)){
            return RestBody.ok("验证码发送成功");
        }
        return RestBody.failure(-999,"验证码发送失败");
    }

}

3. Интеграция в весеннюю безопасность

Следующие уроки должны использовать знания, представленные в предыдущих двух. Если мы хотим реализовать вход с кодом подтверждения, мы должны определитьServlet Filterдля обработки. Его роль повторяется здесь:

  • Перехватывать интерфейс входа по SMS.
  • Получить параметры входа и инкапсулировать какAuthenticationРеквизиты для входа.
  • сдаватьAuthenticationManagerСертификация.

Нам нужно сначала настроитьAuthenticationиAuthenticationManager

3.1 Учетные данные CAPTCHA

AuthenticationНа мой взгляд, это носитель.До аутентификации он используется для передачи ключевых параметров входа, таких как имя пользователя и пароль, и проверочный код, после успешной аутентификации он несет информацию о пользователе и набор ролей. так подражатьUsernamePasswordAuthenticationTokenдля достиженияCaptchaAuthenticationToken, Удалите ненужные функции, скопируйте палец:

package cn.felord.spring.security.captcha;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;

import java.util.Collection;

/**
 * 验证码认证凭据.
 * @author felord.cn
 */
public class CaptchaAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private final Object principal;
    private String captcha;

    /**
     * 此构造函数用来初始化未授信凭据.
     *
     * @param principal   the principal
     * @param captcha the captcha
     * @see CaptchaAuthenticationToken#CaptchaAuthenticationToken(Object, String, Collection)
     */
    public CaptchaAuthenticationToken(Object principal, String captcha) {
        super(null);
        this.principal =  principal;
        this.captcha = captcha;
        setAuthenticated(false);
    }

    /**
     * 此构造函数用来初始化授信凭据.
     *
     * @param principal       the principal
     * @param captcha     the captcha
     * @param authorities the authorities
     * @see CaptchaAuthenticationToken#CaptchaAuthenticationToken(Object, String)
     */
    public CaptchaAuthenticationToken(Object principal, String captcha,
                                      Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.captcha = captcha;
        super.setAuthenticated(true); // must use super, as we override
    }

    public Object getCredentials() {
        return this.captcha;
    }

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

    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            throw new IllegalArgumentException(
                    "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        }

        super.setAuthenticated(false);
    }

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

3.2 Менеджер проверки подлинности CAPTCHA

Нам также необходимо настроитьAuthenticationManagerк учетным данным, определенным вышеCaptchaAuthenticationTokenВыполните обработку аутентификации. Следующую картинку нужно снова вынуть:

ProviderManager

определятьAuthenticationManagerПросто нужно определить его реализациюProviderManager. иProviderManagerнужно зависеть отAuthenticationProvider. Поэтому мы должны реализовать специальныйCaptchaAuthenticationTokenизAuthenticationProvider.AuthenticationProviderПроцесс:

  1. отCaptchaAuthenticationTokenПолучите свой номер телефона и код подтверждения.
  2. Используйте номер мобильного телефона, чтобы запросить информацию о пользователе из базы данных и определить, является ли пользователь действительным пользователем, на самом деле, чтобы достичьUserDetailsServiceинтерфейс
  3. Проверка проверочного кода.
  4. Если проверка прошла успешно, доверенные учетные данные инкапсулируются.
  5. Ошибка проверки вызывает исключение проверки подлинности.

В соответствии с этим процессом реализация выглядит следующим образом:

package cn.felord.spring.security.captcha;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.Assert;

import java.util.Collection;
import java.util.Objects;

/**
 * 验证码认证器.
 * @author felord.cn
 */
@Slf4j
public class CaptchaAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware {
    private final GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
    private final UserDetailsService userDetailsService;
    private final CaptchaService captchaService;
    private MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();

    /**
     * Instantiates a new Captcha authentication provider.
     *
     * @param userDetailsService the user details service
     * @param captchaService     the captcha service
     */
    public CaptchaAuthenticationProvider(UserDetailsService userDetailsService, CaptchaService captchaService) {
        this.userDetailsService = userDetailsService;
        this.captchaService = captchaService;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Assert.isInstanceOf(CaptchaAuthenticationToken.class, authentication,
                () -> messages.getMessage(
                        "CaptchaAuthenticationProvider.onlySupports",
                        "Only CaptchaAuthenticationToken is supported"));

        CaptchaAuthenticationToken unAuthenticationToken = (CaptchaAuthenticationToken) authentication;

        String phone = unAuthenticationToken.getName();
        String rawCode = (String) unAuthenticationToken.getCredentials();

        UserDetails userDetails = userDetailsService.loadUserByUsername(phone);

        // 此处省略对UserDetails 的可用性 是否过期  是否锁定 是否失效的检验  建议根据实际情况添加  或者在 UserDetailsService 的实现中处理
        if (Objects.isNull(userDetails)) {
            throw new BadCredentialsException("Bad credentials");
        }

        // 验证码校验
        if (captchaService.verifyCaptcha(phone, rawCode)) {
            return createSuccessAuthentication(authentication, userDetails);
        } else {
            throw new BadCredentialsException("captcha is not matched");
        }

    }

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

    @Override
    public void afterPropertiesSet() throws Exception {
        Assert.notNull(userDetailsService, "userDetailsService must not be null");
        Assert.notNull(captchaService, "captchaService must not be null");
    }

    @Override
    public void setMessageSource(MessageSource messageSource) {
        this.messages = new MessageSourceAccessor(messageSource);
    }

    /**
     * 认证成功将非授信凭据转为授信凭据.
     * 封装用户信息 角色信息。
     *
     * @param authentication the authentication
     * @param user           the user
     * @return the authentication
     */
    protected Authentication createSuccessAuthentication(Authentication authentication, UserDetails user) {

        Collection<? extends GrantedAuthority> authorities = authoritiesMapper.mapAuthorities(user.getAuthorities());
        CaptchaAuthenticationToken authenticationToken = new CaptchaAuthenticationToken(user, null, authorities);
        authenticationToken.setDetails(authentication.getDetails());

        return authenticationToken;
    }

}

Затем вы можете собратьProviderManagerА:

ProviderManager providerManager = new ProviderManager(Collections.singletonList(captchaAuthenticationProvider));

проходить через3.1и3.2подготовка, наши приготовления завершены.

3.3 Фильтр аутентификации CAPTCHA

После настройки учетных данных капчи и менеджера аутентификации капчи мы можем определить фильтры аутентификации капчи. немного отредактироватьUsernamePasswordAuthenticationFilterможет удовлетворить потребности:

package cn.felord.spring.security.captcha;

import org.springframework.lang.Nullable;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class CaptchaAuthenticationFilter extends AbstractAuthenticationProcessingFilter {


    public static final String SPRING_SECURITY_FORM_PHONE_KEY = "phone";
    public static final String SPRING_SECURITY_FORM_CAPTCHA_KEY = "captcha";


    public CaptchaAuthenticationFilter() {
        super(new AntPathRequestMatcher("/clogin", "POST"));
    }

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

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

        String phone = obtainPhone(request);
        String captcha = obtainCaptcha(request);

        if (phone == null) {
            phone = "";
        }

        if (captcha == null) {
            captcha = "";
        }

        phone = phone.trim();

        CaptchaAuthenticationToken authRequest = new CaptchaAuthenticationToken(
                phone, captcha);

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        return this.getAuthenticationManager().authenticate(authRequest);
    }

    @Nullable
    protected String obtainCaptcha(HttpServletRequest request) {
        return request.getParameter(SPRING_SECURITY_FORM_CAPTCHA_KEY);
    }

    @Nullable
    protected String obtainPhone(HttpServletRequest request) {
        return request.getParameter(SPRING_SECURITY_FORM_PHONE_KEY);
    }

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

}

Здесь мы указываем, что код проверки перехватчика запроса приземляется:

POST /clogin?phone=手机号&captcha=验证码 HTTP/1.1
Host: localhost:8082

Дальше настраивается.

3.4 Конфигурация

Я собрал всю конфигурацию, связанную с аутентификацией по капче, и добавил комментарии.

package cn.felord.spring.security.captcha;

import cn.hutool.core.util.RandomUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.util.StringUtils;

import java.util.Collections;
import java.util.Objects;

/**
 * 验证码认证配置.
 *
 * @author felord.cn
 * @since 13 :23
 */
@Slf4j
@Configuration
public class CaptchaAuthenticationConfiguration {
    private static final String SMS_CAPTCHA_CACHE = "captcha";

    /**
     * spring cache 管理验证码的生命周期.
     *
     * @return the captcha cache storage
     */
    @Bean
    CaptchaCacheStorage captchaCacheStorage() {
        return new CaptchaCacheStorage() {

            @CachePut(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
            @Override
            public String put(String phone) {
                return RandomUtil.randomNumbers(5);
            }

            @Cacheable(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
            @Override
            public String get(String phone) {
                return null;
            }

            @CacheEvict(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
            @Override
            public void expire(String phone) {

            }
        };
    }

    /**
     * 验证码服务.
     * 两个功能: 发送和校验.
     *
     * @param captchaCacheStorage the captcha cache storage
     * @return the captcha service
     */
    @Bean
    public CaptchaService captchaService(CaptchaCacheStorage captchaCacheStorage) {
        return new CaptchaService() {
            @Override
            public boolean sendCaptcha(String phone) {
                String existed = captchaCacheStorage.get(phone);
                if (StringUtils.hasText(existed)) {
                    // 节约成本的话如果缓存存在可用的验证码 不再发新的验证码
                    log.warn("captcha code 【 {} 】 is available now", existed);
                    return false;
                }
                // 生成验证码并放入缓存
                String captchaCode = captchaCacheStorage.put(phone);
                log.info("captcha: {}", captchaCode);

                //todo 这里自行完善调用第三方短信服务
                return true;
            }

            @Override
            public boolean verifyCaptcha(String phone, String code) {
                String cacheCode = captchaCacheStorage.get(phone);

                if (Objects.equals(cacheCode, code)) {
                    // 验证通过手动过期
                    captchaCacheStorage.expire(phone);
                    return true;
                }
                return false;
            }
        };
    }

    /**
     * 自行实现根据手机号查询可用的用户,这里简单举例.
     * 注意该接口可能出现多态。所以最好加上注解@Qualifier
     *
     * @return the user details service
     */
    @Bean
    @Qualifier("captchaUserDetailsService")
    public UserDetailsService captchaUserDetailsService() {
        // 验证码登陆后密码无意义了但是需要填充一下
        return username -> User.withUsername(username).password("TEMP")
                //todo  这里权限 你需要自己注入
                .authorities(AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_APP")).build();
    }

    /**
     * 验证码认证器.
     *
     * @param captchaService     the captcha service
     * @param userDetailsService the user details service
     * @return the captcha authentication provider
     */
    @Bean
    public CaptchaAuthenticationProvider captchaAuthenticationProvider(CaptchaService captchaService,
                                                                       @Qualifier("captchaUserDetailsService")
                                                                               UserDetailsService userDetailsService) {
        return new CaptchaAuthenticationProvider(userDetailsService, captchaService);
    }


    /**
     * 验证码认证过滤器.
     *
     * @param authenticationSuccessHandler  the authentication success handler
     * @param authenticationFailureHandler  the authentication failure handler
     * @param captchaAuthenticationProvider the captcha authentication provider
     * @return the captcha authentication filter
     */
    @Bean
    public CaptchaAuthenticationFilter captchaAuthenticationFilter(AuthenticationSuccessHandler authenticationSuccessHandler,
                                                                   AuthenticationFailureHandler authenticationFailureHandler,
                                                                   CaptchaAuthenticationProvider captchaAuthenticationProvider) {
        CaptchaAuthenticationFilter captchaAuthenticationFilter = new CaptchaAuthenticationFilter();
        // 配置 authenticationManager
        ProviderManager providerManager = new ProviderManager(Collections.singletonList(captchaAuthenticationProvider));
        captchaAuthenticationFilter.setAuthenticationManager(providerManager);
        // 成功处理器
        captchaAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
        // 失败处理器
        captchaAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);

        return captchaAuthenticationFilter;
    }
}

Однако это еще не конец, нужноCaptchaAuthenticationFilterнастроить весьSpring SecurityВ цепочке фильтров такой тип студентов, которые смотрели учебник Fat Brother, должен быть хорошо знаком с ним.

配置验证码认证过滤器到WebSecurityConfigurerAdapter中

Пожалуйста, обратите особое внимание: убедитесь, что к интерфейсу входа и интерфейсу кода подтверждения можно получить анонимный доступ. Если это динамическое разрешение, вы можете добавить его в интерфейс.ROLE_ANONYMOUSРоль.

Готово, тест выглядит следующим образом:

模拟验证码登录

И оригинальный метод входа не затрагивается.

4. Резюме

сквозьUsernamePasswordAuthenticationFilterиAuthenticationManagerсистемное обучение, мы узналиSpring SecurityВесь процесс сертификации, эта статья является практическим применением этих двух статей. Думаю, после прочтения этой статьи вас не смутят иллюстрации в предыдущих статьях, это тоже попытка от теории к практике. DEMO можно получить через соответствующие статьи в личном блоге felord.cn.

关注公众号:Felordcn获取更多资讯

Личный блог: https://felord.cn