前前言
在之前成功使用了阿里云的短信服务发送验证码后,想将这一功能集成在自己的项目中。项目中已经有了账号密码登录,而后端使用的是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 都可以。不过对于多数情况下来看,团队开发是比较多的,所以对代码的设计并不能只针对个人...说不下去了,就这样吧.....