登录模块集成若依 Spring Security 并使用策略模式实现账号、短信验证码等不同登录方式

前前言

        在之前成功使用了阿里云的短信服务发送验证码后,想将这一功能集成在自己的项目中。项目中已经有了账号密码登录,而后端使用的是Spring Security(后面简称 SS),想添加这一功能还需要稍微深入一下 SS 的源码。同时想了解一下策略模式,所以使用策略模式实现不同登录策略的实现。

前言

本文章采用若依框架集成的 Spring Security 实现多种登录方式。

只展示核心代码。

正文

策略模式

实在说策略模式还是第一次去研究这么深,有什么不同的意见可以在评论区友好交流。我接纳所有不同的观点并乐于深入不同的想法。

首先我们先来看一下如果想要在原有的 Controller 层添加一个登录策略该怎么做

参数 loginBody 中有一个属性为 loginType 由前端传来,取出并进行判断,调用不同的 login 方法进行用户的认证和授权然后返回 token 并返回给前端。

抛去细节,这样代码的设计有没有什么问题?似乎没什么问题,简单易懂、维护也好像没有想象中的复杂,想要添加策略我们只需要往后在添加一个 if else 即可,检查方法也可以直接直接进入对应的 login 方法中。

那么策略模式是什么?怎么用?有什么作用?

策略模式的核心大概为三部分:策略接口、具体的策略、上下文。对于上下文这一块的实现有很多种方法,我会分享我看到的一种。

首先是策略接口

上下文 

 直接附上代码

/**
 * <p>
 *
 * @description : 策略模式首相类
 * <p>
 *      策略上下文
 * @Author : Ryan/Rui.Zhang
 * @since : 2024/9/23.
 */

public abstract class AbstractLogin implements LoginState{

    public static ConcurrentHashMap<String, AbstractLogin> map = new ConcurrentHashMap<>();

    @PostConstruct
    private void init(){
        map.put(getLoginType(), this);
    }

    /**
     * 通用登录接口
     *
     * @param loginBody 登录信息
     * @return
     */
    @Override
    public String login (LoginBody loginBody) {
        return loginProcessor(loginBody);
    }


    /**
     * 在子类中声明登录类型
     *
     * @return 登录类型
     */
    protected abstract String getLoginType();

    /**
     * 登录执行器
     *
     * @param loginBody 登录信息
     * @return 登录用户信息
     */
    protected abstract String loginProcessor(LoginBody loginBody);

}

上下文的核心就是根据不同的类型选择不同的策略,本质我认为还是 if else,不过并不是我们来写,而是让程序根据传入的参数来自己判断要选择哪一个具体的策略。

所以我们应该记录所有的策略,这里用到了 map 集合,核心就在加了 @PostConstruct注解的init方法,其中的 this 指的是 AbstractLogin 类的实例,也就是我们接下来要实现的具体的策略。于是乎我们就将所以策略的具体实现添加进了map 中,使用的时候获得map再根据登录类型执行login方法就行了。先来看具体的策略

具体的策略--账号密码
/**
 * <p>
 *
 * @description : 登录策略具体实现--账号密码登录
 * <p>
 * @Author : Ryan/Rui.Zhang
 * @since : 2024/9/23.
 */
@Service
@RequiredArgsConstructor
public class AccountLoginProcessor extends AbstractLogin {

    private final SysLoginService sysLoginService;

    /**
     * 获取登录类型
     * 
     * @return 登录类型
     */
    @Override
    protected String getLoginType () {
        return UserLoginType.ACCOUNT.getVal();
    }

    @Override
    protected String loginProcessor (LoginBody loginBody) {
        // 调用账号密码登录接口
        return sysLoginService.login(loginBody);
    }
}

这里注入SysLoginService 并执行login方法。UserLoginType 类是一个枚举,指定了登录的类型,来看代码

/**
 * <p>
 *
 * @description : 用户登录方式
 * <p>
 * @Author : Ryan/Rui.Zhang
 * @since : 2024/9/22.
 */
@Getter
public enum UserLoginType {

    /**
     * 默认,账号密码登录
     */
    ACCOUNT("1"),

    /**
     * 短信验证码登录
     */
    MOBILE("2"),

    /**
     * 邮箱验证码登录
     */
    EMAIL("3");

    private String val;


    UserLoginType (String val) {
        this.val = val;
    }

    public void setVal (String val) {
        this.val = val;
    }
}
具体的策略--短信验证码

怎么调用发送短信的api可以看我前面的文章,这里不展示那部分的代码了。

/**
 * <p>
 *
 * @description : 登录策略具体实现--短信验证码登录
 * <p>
 * @Author : Ryan/Rui.Zhang
 * @since : 2024/9/23.
 */
@Service
@RequiredArgsConstructor
public class MobileLoginProcessor extends AbstractLogin {

    private final SysLoginService loginService;

    @Override
    protected String getLoginType () {
        return UserLoginType.MOBILE.getVal();
    }

    @Override
    protected String loginProcessor (LoginBody loginBody) {
        // 调用电话短信登录服务;
        return loginService.loginMobile(loginBody);
    }
}

这里和账号密码登录的差不多。

改造
/**
     * 根据类型登录
     *
     * @param type 登录类型
     * @param loginBody 登录信息
     * @return token
     */
    private String abstractLogin(String type, LoginBody loginBody){
        AbstractLogin abstractLogin = AbstractLogin.map.get(type);
        if (ObjectUtil.isNull(abstractLogin)){
            throw new ServiceException();
        }
        return abstractLogin.login(loginBody);
    }

    /**
     * 登录方法
     * 
     * @param loginBody 登录信息
     * @return 结果
     */
    @PostMapping("/login")
    public AjaxResult login(@RequestBody LoginBody loginBody) {
        // 获取token
        String token = abstractLogin(loginBody.getLoginType(), loginBody);
        return AjaxResult.success().put(Constants.TOKEN, token);
    }

好了,现在不出意外的话就可以执行了,代码非常的优雅,详细可以看原博客:我是原博客

集成若依Spring Security 

接下来只需要编写 Service 层的逻辑代码,因为我使用了若依框架,已经提前集成了Spring Security并编写了账号密码登录的代码,我只需要根据原本的代码编写其他策略的代码就可以,先看已经提供的代码

public String login(LoginBody loginBody)
    {
        String username = loginBody.getUsername();
        String password = loginBody.getPassword();
        String code = loginBody.getCode();
        String uuid = loginBody.getUuid();
        // 验证码校验
        validateCaptcha(username, code, uuid);
        // 登录前置校验
        loginPreCheck(username, password);
        // 用户验证
        Authentication authentication = null;
        try {
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(primary, password);
            // 存入ThreadLocal上下文
            AuthenticationContextHolder.setContext(authentication);
            authentication = authenticationManager.authenticate(authentication);
        } catch (Exception e){}
        finally
        {
            AuthenticationContextHolder.clearContext();
        }
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(primary, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        recordLoginInfo(loginUser.getUserId());
        // 生成token
        return tokenService.createToken(loginUser);
    }

这里面核心流程就是try代码块中的部分,setContext 是将用户信息存入上下文,就是 ThreadLocal,这个并不是重点,重点是前面的一行和后面的一行。

使用Spring Securtiy 其实就做了两件事,用户的认证和授权,那么这里的两行做了认证,授权并不在这里,下面再说,先说认证。我看了其他博客觉得简单来看就三样东西,一个是 filters拦截器,Provider 用于处理拦截到的信息,Token 作为一个标识。而在这里并没有用到Fillters,所以之说后面两个,大概的流程就是创建一个Token,随后调用authenticate方法并传入Token,随后会根据你的Token类型执行对应的Provider方法,在这个方法中进行授权等流程。

因为Username什么什么Token 是Spring Security提供的,我们无法修改,那我们是不是可以尝试仿照这个类编写一个我们的Token,然后编写一个自己的Provider处理我们的逻辑,来看代码

/**
 * <p>
 *
 * @description : 自定义短信验证码登录token
 * <p>
 * @Author : Ryan/Rui.Zhang
 * @since : 2024/9/24.
 */

public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
    

    private final Object principal;

    public SmsCodeAuthenticationToken (Object principal) {
        super(null);
        this.principal = principal;
        setAuthenticated(false);
    }

    public SmsCodeAuthenticationToken (Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        setAuthenticated(true);
    }

    @Override
    public Object getCredentials () {
        return null;
    }

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


    public void setAuthenticated(boolean authenticated) {
        if (authenticated) {
            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();
    }
}

因为我们这里创建的是短信验证码Token,这个principal 对应着的就是phone电话,也不需要code验证码,因为前面已经校验过了,至于在哪里可以往前翻,在try代码块的前面,具体我就不多说了,所以我们这里只需要一个principal,将另外一个重写的getCredentials方法返回null就行。

来看Provider

/**
 * <p>
 * 
 * @description : 短信验证码登录验证流程
 * <p>
 * @Author : Ryan/Rui.Zhang
 * @since : 2024/9/24.
 */
@Data
@Component
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsServiceImpl userDetailsService;

    public SmsCodeAuthenticationProvider (UserDetailsServiceImpl userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Override
    public Authentication authenticate (Authentication authentication) throws AuthenticationException {

        SmsCodeAuthenticationToken authToken = (SmsCodeAuthenticationToken) authentication;

        String phone = (String) authentication.getPrincipal();  // 获取手机号

        UserDetails user = userDetailsService.loadUserByMobile(phone);

        // 封装认证结果
        SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user);
        authenticationResult.setDetails(authToken.getDetails());
        return authenticationResult;
    }

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

先来说一下代码,并留下一个思考:前面 Service 层中调用了authenticate 方法,该方法会选择Token对应的Provider类进入并执行,那么,他是怎么做到的?

这里的代码就比较简单了,创建Token,传入Service 层进行授权等操作并返回一个对象,并重新打包返回。

看代码的下面有个 supports方法,这个就是上面问题的答案,Spring Security 在调用 authenticate方法时会遍历所有的Provider,调用每一个supports并进行匹配Token是否是当前类的Token,匹配成功后就进入主题进行授权的操作。

至于用户授权的具体操作我就不带大家看了,主要我也需要继续学习。

那么到此,我们应该可以写出 LoginService 层关于具体登录策略的用户认证代码了,来看代码

    /**
     * 登录验证
     * 
     * @param loginBody 用户信息
     * @return 结果
     */
    public String login(LoginBody loginBody)
    {
        String username = loginBody.getUsername();
        String password = loginBody.getPassword();
        String code = loginBody.getCode();
        String uuid = loginBody.getUuid();
        // 验证码校验
        validateCaptcha(username, code, uuid);
        // 登录前置校验
        loginPreCheck(username, password);
        // 用户验证
        return authenticateAuthorizationAndToken(username, new UsernamePasswordAuthenticationToken(username, password));
    }

    /**
     * 手机登录验证
     *
     * @param loginBody 用户信息
     * @return token
     */
    public String loginMobile(LoginBody loginBody){
        String phone = loginBody.getPhone();
        String code = loginBody.getCode();
        // 验证码校验
        // validateMobile(phone, code);
        // 登录前置校验
        // loginPreCheckMobile(phone, code);
        return authenticateAuthorizationAndToken(phone, new SmsCodeAuthenticationToken(phone));
    }


    public String authenticateAuthorizationAndToken(String primary, AbstractAuthenticationToken token){
        Authentication authentication = null;
        try {
            // 存入ThreadLocal上下文
            AuthenticationContextHolder.setContext(token);
            authentication = authenticationManager.authenticate(token);
        } catch (Exception e)
        {
            if (e instanceof BadCredentialsException)
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(primary, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
                throw new UserPasswordNotMatchException();
            }
            else
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(primary, Constants.LOGIN_FAIL, e.getMessage()));
                throw new ServiceException(e.getMessage());
            }
        }
        finally
        {
            AuthenticationContextHolder.clearContext();
        }
        // 异步任务管理器,记录用户登录
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(primary, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
        // 从Token中获取用户信息
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        // 记录用户登录日志
        recordLoginInfo(loginUser.getUserId());
        // 生成token
        return tokenService.createToken(loginUser);
    }

至于其中的登录校验什么的可以根据自己的需求自己来写,我就不展示具体的代码了。


那么到此,我认为一个比较看的上眼的使用策略模式实现的不同登录并集成Spring Security就实现了。有什么不明白,或者有任何意见都可以在评论区进行提问或交流。

最后我回复最开始提出的问题,策略模式有什么用?

我认为,长的我懒得说,短的我认为,装X呗.....

对于个人开发来讲,毕竟是自己写,可以随便写,多少个if else 都可以。不过对于多数情况下来看,团队开发是比较多的,所以对代码的设计并不能只针对个人...说不下去了,就这样吧.....

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值