优雅的使用Spring Security完成多种登录方式 (含Security源码讲解)

一、简介

本文介绍 YF开源项目 中如何使用 SpringSecurity 实现多种登录方式,对于 SpringSecurity 的基础认证流程、以及其中使用到的多种设计模式进行讲解。

二、前置知识

  1. 了解SpringSecurity ( 本文也会简单介绍 )
  2. 了解工厂模式、策略模式(可选)
  3. 了解JustAuth,第三方登录采用JustAuth进行实现(可选)

三、编写具体实现

简单介绍 SpringSecurity 的授权认证流程

本节介绍DaoAuthenticationProviderSpring Security 的工作原理。下图解释了读取用户名和密码AuthenticationManager部分中的工作原理。

看完上图我做一个简单的讲解,其实主要讲解的就是我们如何将授权信息返回 , SpringSecurity中会将 UsernamePasswordAuthenticationToken 中携带的信息传入 AuthenticationManager.authenticate() 方法进行登录 ,具体则是使用 ProviderManager 进行后续操作 , 如图

当我们点入源码会看到 ProviderManager , 最终还是会调用 DaoAuthenticationProvider.authenticate() 去获取 Authenticate 对象。

DaoAuthenticationProvider 是通过继承 AbstractUserDetailsAuthenticationProvider 来进行登录实现,并未重写 AbstractUserDetailsAuthenticationProvider 中的 authenticate 方法,所以我们最终还是使用的是 AbstractUserDetailsAuthenticationProvider.authenticate() , 而DaoAuthenticationProvider 只是重写了 retrieveUser() 方法来获取具体的 UserDetails 对象。

其中 AbstractUserDetailsAuthenticationProvider.authenticate() 的主要流程 源码附上 :

java 复制代码
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    // 确保传入的 authentication 参数是 UsernamePasswordAuthenticationToken 类型
    Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
            () -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
                    "Only UsernamePasswordAuthenticationToken is supported"));

    // 从传入的 authentication 对象中提取用户名
    String username = determineUsername(authentication);
    boolean cacheWasUsed = true;

    // 从用户缓存中尝试获取用户信息
    UserDetails user = this.userCache.getUserFromCache(username);
    if (user == null) {
        cacheWasUsed = false;
        try {
            // 如果缓存中没有用户信息,从用户存储(如数据库)中检索用户信息
            user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
        } catch (UsernameNotFoundException ex) {
            // 如果用户未找到,记录调试信息
            this.logger.debug("Failed to find user '" + username + "'");
            if (!this.hideUserNotFoundExceptions) {
                throw ex;
            }
            // 如果需要隐藏用户未找到异常,抛出通用的凭证错误异常
            throw new BadCredentialsException(this.messages
                .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        }
        // 确保 retrieveUser 方法返回的用户对象不为空
        Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
    }

    try {
        // 执行预认证检查
        this.preAuthenticationChecks.check(user);
        // 进行额外的身份验证检查(通常是验证密码)
        additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
    } catch (AuthenticationException ex) {
        // 如果身份验证失败且用户信息不是从缓存中获取的,抛出异常
        if (!cacheWasUsed) {
            throw ex;
        }
        // 如果身份验证失败且用户信息是从缓存中获取的,从用户存储中重新获取最新用户信息并重试
        cacheWasUsed = false;
        user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
        this.preAuthenticationChecks.check(user);
        additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
    }

    // 执行后认证检查
    this.postAuthenticationChecks.check(user);

    // 如果用户信息不是从缓存中获取的,将用户信息放入缓存
    if (!cacheWasUsed) {
        this.userCache.putUserInCache(user);
    }

    // 返回的 principal 对象,默认为 user,如果 forcePrincipalAsString 为 true,则返回用户名
    Object principalToReturn = user;
    if (this.forcePrincipalAsString) {
        principalToReturn = user.getUsername();
    }

    // 创建并返回认证成功的 Authentication 对象
    return createSuccessAuthentication(principalToReturn, authentication, user);
}

其中主要目的是为了返回一个认证成功的 Authentication 对象 , 而当我们需要自定义认证校验的时候,就可以参照该方法进行编写,以达到多种登录方式(后续逐步介绍)。

注意 : userCache 在目前的系统架构中其实是可有可无的 , userCache 使用位置集中在 '记住我' 、存储UserDetails,在多点登录中可做到清除缓存强制下线等功能。但是在本系统中 UserCache 就变的可有可无 。 我们采用 Redis 作为缓存用户信息 , 前后端分离进行开发 ,Jwt 为认证授权以及 token 刷新处理 , 如果我们继续维护 userCache 可能还会成为负担,所以我们自定义的登录方式不再维护userCache , 但并不是说 UserCache无作用。

源码大致介绍到这里就够用了,最后再了解一下 UsernamePasswordAuthenticationToken 中的参数,其实可以理解为 principal 主体 ,credentials 凭据 ,而不定义为单纯的用户名密码。

java 复制代码
/**
 * This constructor can be safely used by any code that wishes to create a
 * <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()}
 * will return <code>false</code>.
 *
 */
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
    super(null);
    this.principal = principal;
    this.credentials = credentials;
    setAuthenticated(false);
}

我们在 Security 中需要的重构部分

看完以上的调用流程,我们如何自定义多种登录方式呢,想必大家应该都知道,我们最终会使用 AbstractUserDetailsAuthenticationProvider 返回授权对象 ,针对这一个点,我们可以让ProviderManager加载我们自定义的Provider , 然后修改为我们想要的认证方式即可 ,例如 :

java 复制代码
public class CustomAuthenticationProvider extends DaoAuthenticationProvider {

    // 重写 authenticate() 方法自定义返回 Authentication 对象即可
}

但是如果只传入用户名密码是不可能完成我们需要的多种登录方式的要求,那么就需要在入参 中进行分析了。 对于 UsernamePasswordAuthenticationToken.principal 用于传入各种登录方式所需要内容,而 UsernamePasswordAuthenticationToken.credentials 用于传入登录类型 ,如下:

java 复制代码
@Override
public LoginResult login(LoginForm loginForm, LoginTypeEnum type) {
    /*
    参数说明 : principal 主体 {
          "username": null,
          "phoneNumber": null,
          "email": null,
          "password": null,
          "verifyCode": null,
          "verifyCodeKey": null,
          "smsCode": null,
          "emailCode": null,
          "oauth": {
            "code": null,
            "auth_code": null,
            "state": null,
            "authorization_code": null,
            "oauth_token": null,
            "oauth_verifier": null
          }
    } ,credentials 凭据 { type : 不同登录方式}
    */ 
    UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(loginForm, type);
    // 1. 获取到 UserDetails 对象
    Authentication authenticate = authenticationManager.authenticate(authenticationToken);
    // 2. 生成 Jwt Token;
    return jwtUtil.getLoginResult(authenticate);
}

在调用到我们的provider中时,我们可以通过不同的登录类型进行不同的操作,以下是没有使用任何设计模式的简单实现 :

java 复制代码
public class CustomAuthenticationProvider extends DaoAuthenticationProvider {
        
    // 注入 passwordEncoder、userDetailsService


    @Override
    @Transactional
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
       LoginTypeEnum  type =  (LoginTypeEnum) authentication.getCredentials();
       if( type == LoginTypeEnum.USERNAME_PASSWORD) {
           // 1. 校验用户名密码 、 验证码
           // 2. 其他操作
           // 3. 查询 UserDetails 对象
           // 4. 其他操作
           // 5. 后置校验
       } else if ( type == LoginTypeEnum.EMAIL ) {
           // 1. 校验邮箱、邮箱验证码
           // 2. 其他操作
           // 3. 查询 UserDetails 对象
           // 4. 其他操作
           // 5. 后置校验
       } else if ( ... ) {
           // ...
           // 1. 校验对应登录方式
           // 2. 其他操作
           // 3. 查询 UserDetails 对象
           // 4. 其他操作
           // 5. 后置校验
       } ...
    
    }
}

到这里大家应该知道我们可以如何实现多种登录方式,只是在 authenticate 中的代码并不优雅,也不方便扩展,这也是我们实际开发中需要解决的问题 , 让我简单的贴个代码,让大家了解到稍后用到的设计模式(只是一个简单演示,可单独运行):

1. 策略模式、适配器模式

我们首先定义一个 ILoginProcessTemplate 来作为多种登录方式都应该有的流程,默认实现部分可能不需要实现的方法。

java 复制代码
interface ILoginProcessTemplate {
    void validateParameters();
    boolean isRegisteredUser();
    // 默认实现:如果用户未注册,执行自动注册 
    default void registerUser() {  }
    void loginUser();
}

class UsernamePasswordLoginStrategy implements ILoginProcessTemplate {
    public void validateParameters() {
        // 实现用户名密码校验
    }

    public boolean isRegisteredUser() {
        // 检查用户是否已注册
        return true;
    }

    public void loginUser() {
        // 用户登录
    }
}

class EmailLoginStrategy implements ILoginProcessTemplate {
    public void validateParameters() {
        // 实现邮箱校验
    }

    public boolean isRegisteredUser() {
        // 检查用户是否已注册
        return true;
    }

    public void registerUser() {
        // 注册用户
    }

    public void loginUser() {
        // 用户登录
    }
}

2. 工厂模式

在 LoginStrategyFactory 中加载登录类型所对应的具体工厂

java 复制代码
enum LoginTypeEnum {
    USERNAME_PASSWORD,
    EMAIL
}

class LoginStrategyFactory {
    private static final EnumMap<LoginTypeEnum, ILoginProcessTemplate> loginStrategyMap = new EnumMap<>(LoginTypeEnum.class);

    static {
        loginStrategyMap.put(LoginTypeEnum.USERNAME_PASSWORD, new UsernamePasswordLoginStrategy());
        loginStrategyMap.put(LoginTypeEnum.EMAIL, new EmailLoginStrategy());
    }

    public static ILoginProcessTemplate getStrategy(LoginTypeEnum loginType) {
        return loginStrategyMap.get(loginType);
    }
}

3. 使用示例

有以上操作以后,我们的使用将变的很简单。只���要几行即可完成,对于后续新增不同的登录方式,也不会让调用者的代码更改,而更关注与自己登录方式的具体实现。

java 复制代码
// 主程序类
public class LoginClient {
    public static void main(String[] args) {
        // 1. 获取登录策略
        ILoginProcessTemplate loginStrategy = LoginStrategyFactory.getStrategy(LoginTypeEnum.USERNAME_PASSWORD);
        
        // 2. 执行登录流程
        loginStrategy.validateParameters();  // 校验参数
        if (!loginStrategy.isRegisteredUser()) {  // 判断用户是否注册
            loginStrategy.registerUser();  // 如果未注册,执行注册操作
        }
        loginStrategy.loginUser();  // 执行登录操作
    }
}

在项目中编写具体代码

我再简述一下大致流程以免大家混乱,我们需要让 UsernamePasswordAuthenticationToken 携带对应的登录方式信息、登录方式,传入 authenticationManager.authenticate() 中调用我们自定义的 Provider,而自定义的Provider 是重写 DaoAuthenticationProvider 类中 authenticate 方法来切入我们自己的逻辑。

登录接口具体服务 AuthServiceImpl.java

java 复制代码
    /**
     * 本地登陆 + 第三方登录
     *
     * @param loginForm 登陆表单
     * @param type      登陆类型
     * @return LoginResult
     */
    @Override
    public LoginResult login(LoginForm loginForm, LoginTypeEnum type) {
        // 参数说明 : principal 主体 ,credentials 凭据
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginForm, type);
        // 1. 获取到 UserDetails 对象
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        // 2. 生成 Jwt Token;
        return jwtUtil.getLoginResult(authenticate);
    }

自定义 Provider 继承 DaoAuthenticationProvider 重写 AbstractUserDetailsAuthenticationProvider 的 authenticate 方法,返回一个合法的用户认证信息( 使用到工厂模式、策略模式、适配器模式 ... )

java 复制代码
/**
 * 自定义AuthenticationProvider
 * 角色 : 简单工厂 + 模版方法
 *
 * @author YiFei
 * @since 2024/4/16 18:37
 */
@Component
public class CustomAuthenticationProvider extends DaoAuthenticationProvider {

    // 使用静态初始化块来初始化不同登录类型对应的策略映射
    private final Map<LoginTypeEnum, ILoginProcessTemplate> loginStrategyMap = new EnumMap<>(LoginTypeEnum.class);

    /**
     * 通过构造函数注入`UserDetailsService`和`PasswordEncoder`,
     * 并将它们设置到父类的对应属性中。这一步是必要的,因为`DaoAuthenticationProvider`
     * 需要这些服务来加载用户详情并对密码进行验证。
     *
     * @param userDetailsService 用户服务,提供了一种从持久层加载用户详情的方式。
     * @param passwordEncoder    密码编码器,用于在验证过程中对密码进行编码和匹配。
     */
    @Autowired
    public CustomAuthenticationProvider(List<ILoginProcessTemplate> loginProcessTemplates, UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
        super.setPasswordEncoder(passwordEncoder); // 设置密码编码器
        super.setUserDetailsService(userDetailsService); // 设置用户详情服务
        loginProcessTemplates.forEach(template -> this.loginStrategyMap.put(template.getLoginTypeSupport(), template));
    }

    @Override
    @Transactional
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 1. 根据凭证到工厂获取对象
        ILoginProcessTemplate loginProcessTemplate = loginStrategyMap.get((LoginTypeEnum) authentication.getCredentials());
        if (loginProcessTemplate == null) {
            throw new ServiceException(ResultCode.AUTH_MALICIOUS_LOGIN);
        }
        LoginForm principal = (LoginForm) authentication.getPrincipal();
        // 2. 校验传入参数
        if (!loginProcessTemplate.validateParameters(principal)) {
            throw new ServiceException(ResultCode.AUTH_PARAMETER_ERROR);
        }
        // 3. 自动注册用户
        if (!loginProcessTemplate.registeredUsers(principal)) {
            throw new ServiceException(ResultCode.AUTH_REGISTER_USER_ERROR);
        }
        // 4. 查询用户信息
        UserDetails userDetails = loginProcessTemplate.getUserDetailsByPrincipal(principal);
        if (userDetails == null) {
            throw new ServiceException(ResultCode.AUTH_LOGIN_ERROR);
        }
        // 5. 后置校验用户信息
        if (!loginProcessTemplate.validatePostParameters(principal, userDetails)) {
            throw new ServiceException(ResultCode.AUTH_LOGIN_ERROR);
        }
        // 6. 创建成功的认证令牌并返回
        return createSuccessAuthentication(userDetails, authentication, userDetails);
    }
}

定义一个抽象的适配器策略 ILoginProcessStrategy ,让具体策略可选的实现对应方法。

java 复制代码
/**
 * 登陆过程模板
 *
 * @author YiFei
 * @since 2024/4/16 16:29
 */
public interface ILoginProcessStrategy {

    /**
     * 获取登录类型
     */
    LoginTypeEnum getLoginTypeSupport();

    /**
     * 自动注册用户 : 默认已注册
     */
    default boolean registeredUsers(LoginForm principal) {
        return true;
    }

    /**
     * 校验登录参数
     *
     * @param principal 主体
     */
    boolean validateParameters(LoginForm principal);

    /**
     * 获取用户信息
     */
    UserDetails getUserDetailsByPrincipal(LoginForm principal);

    /**
     * 后置校验用户信息 : 默认校验成功
     */
    default boolean validatePostParameters(LoginForm principal, UserDetails userDetails) {
        return true;
    }
}

具体策略 UsernamePasswordStrategy 实现 ILoginProcessStrategy 类,由于不需要自动注册,所以选择部分方法实现 , 由 ILoginProcessStrategy 进行适配,( 只贴用户名密码登录具体策略 其他策略请看源码 )

java 复制代码
/**
 * 用户密码登录
 *
 * @author YiFei
 * @since 2024/3/6 18:56
 */
@Component
@RequiredArgsConstructor
public class UsernamePasswordStrategy implements ILoginProcessStrategy {

    private final ICaptchaCodeService captchaCodeService;
    private final ISysUserService userService;
    private final PasswordEncoder passwordEncoder;

    /**
     * 支持 USERNAME_PASSWORD
     */
    @Override
    public LoginTypeEnum getLoginTypeSupport() {
        return LoginTypeEnum.USERNAME_PASSWORD;
    }

    /**
     * 校验登录参数
     *
     * @param principal 主体
     */
    @Override
    public boolean validateParameters(LoginForm principal) {
        String username = principal.getUsername();
        String password = principal.getPassword();
        String verifyCode = principal.getVerifyCode();
        String verifyCodeKey = principal.getVerifyCodeKey();
        // 1. 校验用户名
        if (username == null || username.length() < 4) {
            return false;
        }
        // 2. 校验密码
        if (password == null || password.length() < 6) {
            return false;
        }
        // 3. 校验验证码
        if (verifyCode == null || verifyCodeKey == null) {
            return false;
        }
        // 4. 校验缓存验证码
        if (!captchaCodeService.checkVerifyCode(CaptchaCodeForm.builder()
                .verifyCode(verifyCode)         // code
                .verifyCodeKey(verifyCodeKey)   // code key
                .build())) {
            throw new ServiceException(ResultCode.AUTH_CODE_ERROR);
        }
        return true;
    }

    /**
     * 获取用户信息
     */
    @Override
    public UserDetails getUserDetailsByPrincipal(LoginForm principal) {
        // 1. 查询权限信息
        UserAuthInfo userAuthInfo = userService.getUserAuthInfo(principal);
        // 2. 构建 SysUserDetails 信息
        return new SysUserDetails(userAuthInfo);
    }

    /**
     * 后置校验用户信息 : 校验密码是否正确
     */
    @Override
    public boolean validatePostParameters(LoginForm principal, UserDetails userDetails) {
        // 校验密码是否正确
        return passwordEncoder.matches(principal.getPassword(), userDetails.getPassword());
    }

}

部分 SpringSecurity 配置

java 复制代码
// ... 忽略其他

/**
 * 密码编码器
 */
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    return authenticationConfiguration.getAuthenticationManager();
}

@Bean
public UserDetailsService userDetailsService() {
    return new InMemoryUserDetailsManager();
}

四、在线预览以及源码

  • 👀 在线预览

    • 一人一号、账号会自动注册。
    • 手机号登录并未实现。
  • 源码

    • yf/ yf-boot-admin / yf-auth 包下
    • 求个start
相关推荐
Albert Edison3 小时前
【最新版】IntelliJ IDEA 2025 创建 SpringBoot 项目
java·spring boot·intellij-idea
Piper蛋窝4 小时前
深入 Go 语言垃圾回收:从原理到内建类型 Slice、Map 的陷阱以及为何需要 strings.Builder
后端·go
六毛的毛7 小时前
Springboot开发常见注解一览
java·spring boot·后端
AntBlack7 小时前
拖了五个月 ,不当韭菜体验版算是正式发布了
前端·后端·python
31535669137 小时前
一个简单的脚本,让pdf开启夜间模式
前端·后端
uzong7 小时前
curl案例讲解
后端
开开心心就好8 小时前
免费PDF处理软件,支持多种操作
运维·服务器·前端·spring boot·智能手机·pdf·电脑
一只叫煤球的猫8 小时前
真实事故复盘:Redis分布式锁居然失效了?公司十年老程序员踩的坑
java·redis·后端
猴哥源码8 小时前
基于Java+SpringBoot的农事管理系统
java·spring boot
大鸡腿同学9 小时前
身弱武修法:玄之又玄,奇妙之门
后端