逻辑图:
逻辑:
原有用户密码的逻辑:
1.先进入 UsernamePasswordAuthenticationFilter 中,根据输入的用户名和密码信息,构造出一个暂时没有鉴权的 UsernamePasswordAuthenticationToken,并将 UsernamePasswordAuthenticationToken 交给 AuthenticationManager 处理。
2.AuthenticationManager 本身并不做验证处理,他通过 for-each 遍历找到符合当前登录方式的一个 AuthenticationProvider,并交给它进行验证处理,对于用户名密码登录方式,这个 Provider 就是 DaoAuthenticationProvider。
3.在这个 Provider 中进行一系列的验证处理,如果验证通过,就会重新构造一个添加了鉴权的 UsernamePasswordAuthenticationToken,并将这个 token 传回到 UsernamePasswordAuthenticationFilter 中。
4.在该 Filter 的父类 AbstractAuthenticationProcessingFilter 中,会根据上一步验证的结果,跳转到 successHandler 或者是 failureHandler
短信验证码的逻辑:
1.用户名密码登录有个 UsernamePasswordAuthenticationFilter ,我们搞一个 SmsAuthenticationFilter,代码粘过来改一改。
2.用户名密码登录需要 UsernamePasswordAuthenticationToken,我们搞一个 SmsAuthenticationToken,代码粘过来改一改。
3.用户名密码登录需要 DaoAuthenticationProvider,我们模仿它也 implenments AuthenticationProvider,叫做 SmsAuthenticationProvider。
4.先经过 SmsAuthenticationFilter,构造一个没有鉴权的 SmsAuthenticationToken,然后交给 AuthenticationManager 处理。
5.AuthenticationManager 通过 for-each 挑选出一个合适的 provider 进行处理,当然我们希望这个 provider 要是 SmsAuthenticationProvider。
6.验证通过后,重新构造一个有鉴权的 SmsAuthenticationToken,并返回给 SmsAuthenticationFilter。
filter 根据上一步的验证结果,跳转到成功或者失败的处理逻辑。
代码实例
1.创建验证码生成器
package com.lc.gansu.security.smsValidate;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.stereotype.Component;
/**
* TODO 创建验证码生成器
*
* @author songtianxiong
* @version 1.0
* @date 2021/12/17 16:33
*/
@Component
public class SmsCodeGenerator {
public String generate() {
return RandomStringUtils.randomNumeric(4);
}
}
2.验证码发送器
package com.lc.gansu.security.smsValidate;
import org.springframework.stereotype.Component;
/**
* TODO 验证码发送器
*
* @author songtianxiong
* @version 1.0
* @date 2021/12/20 14:22
*/
@Component
public class SmsCodeSender {
public void send(String mobile, String code) {
System.out.println("向手机" + mobile + "发送短信验证码" + code);
}
}
3.发送短信接口
package com.lc.gansu.security.smsValidate;
import com.lc.gansu.security.component.SecurityConstants;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
/**
* TODO 发送短信接口
*
* @author songtianxiong
* @version 1.0
* @date 2021/12/17 16:34
*/
@RestController
public class ValidateCodeController {
@Autowired
private SmsCodeGenerator smsCodeGenerator;
@Resource
private SmsCodeSender smsCodeSender;
@Resource
private RedisTemplate redisTemplate;
@GetMapping("/code/sms")
public String createSmsCode(@RequestParam String mobile) throws IOException {
//获取验证码
String smsCode = smsCodeGenerator.generate();
//把验证码设置到redis
redisTemplate.boundValueOps(SecurityConstants.getValidCodeKey(mobile)).set(smsCode, 300, TimeUnit.SECONDS);
smsCodeSender.send("18360903475", "登录验证码为:" + smsCode + ",五分钟过期");
return "验证码是 : " + smsCode;
}
}
4.token:两个实现方法:未鉴权的和已鉴权的
package com.lc.gansu.security.smsValidate;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
/**
* TODO 两个实现方法:未鉴权的和已鉴权的
* 步骤:
*
* principal 原本代表用户名,这里保留,只是代表了手机号码。
* credentials 原本代码密码,短信登录用不到,直接删掉。
* SmsCodeAuthenticationToken() 两个构造方法一个是构造没有鉴权的,一个是构造有鉴权的。
* 剩下的几个方法去除无用属性即可。
*
* @author songtianxiong
* @version 1.0
* @date 2021/12/17 16:39
*/
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
/**
* UsernamePasswordAuthenticationToken类里代表用户名
* 现在代表手机号
*/
private final Object principal;
/**
* 通过手机号构造未鉴权的token
*/
public SmsCodeAuthenticationToken(Object principal) {
super(null);
this.principal = principal;
setAuthenticated(false);
}
/**
* 通过手机号构造已鉴权的token
*
* @param principal
* @param authorities
*/
public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true); // must use super, as we override
}
@Override
public Object getCredentials() {
return null;
}
@Override
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);
}
}
5.短信登录的鉴权过滤器,模仿 UsernamePasswordAuthenticationFilter
package com.lc.gansu.security.smsValidate;
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 org.springframework.util.Assert;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 短信登录的鉴权过滤器,模仿 UsernamePasswordAuthenticationFilter 实现
*/
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
/**
* form表单中手机号码的字段name
*/
public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";
private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY;
/**
* 是否仅 POST 方式
*/
private boolean postOnly = true;
public SmsCodeAuthenticationFilter() {
// 短信登录的请求 post 方式的 /sms/login
super(new AntPathRequestMatcher("/sms/login", "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String mobile = obtainMobile(request);
if (mobile == null) {
mobile = "";
}
mobile = mobile.trim();
SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(mobileParameter);
}
protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
public String getMobileParameter() {
return mobileParameter;
}
public void setMobileParameter(String mobileParameter) {
Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
this.mobileParameter = mobileParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
}
6.SmsUserDetailsService 用户信息
package com.lc.gansu.security.smsValidate;
import com.lc.gansu.module.sys.domain.Org;
import com.lc.gansu.module.sys.domain.Permission;
import com.lc.gansu.module.sys.domain.Role;
import com.lc.gansu.security.SecurityService;
import com.lc.gansu.security.domain.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.*;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* TODO 参考用户密码的逻辑提供给provider构建上下文用户
*
* @author songtianxiong
* @version 1.0
* @date 2021/12/20 14:22
*/
@Slf4j
public class SmsUserDetailsService implements UserDetailsService {
private final SecurityService securityService;
public SmsUserDetailsService(SecurityService securityService) {
this.securityService = securityService;
}
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.info("开始加载用户 : {}", s);
User sysUser = securityService.retrieveSysUserByPhone(s);
//这里只有BadCredentialsException可以被捕获到,所以给前端的异常信息必须抛出BadCredentialsException
if (Objects.isNull(sysUser)) throw new BadCredentialsException("帐号不存在,请重新输入!");
if (sysUser.getState() != 1) throw new BadCredentialsException("账号已停用!");
if (!sysUser.isEnabled()) {
throw new DisabledException("账号禁用");
} else if (!sysUser.isAccountNonLocked()) {
throw new LockedException("账号锁定");
} else if (!sysUser.isAccountNonExpired()) {
throw new AccountExpiredException("账号过期");
} else if (!sysUser.isCredentialsNonExpired()) {
throw new CredentialsExpiredException("密码过期");
}
Map<Long, List<Role>> sysOrgIdMapSysRoleList = securityService.getOrgIdWithRolesByUserId(sysUser.getId());
if (sysOrgIdMapSysRoleList.size() > 0) {
Long orgId = sysOrgIdMapSysRoleList.keySet().iterator().next();
//加载用户当前的组织
if (orgId == 0) sysUser.setCurrentOrg(new Org().setId(0L).setName("").setState(1));
else sysUser.setCurrentOrg(securityService.getOrgById(orgId));
//加载用户当前组织上的菜单列表
// List<Permission> privileges = securityService.getMenu(sysUser.getId(), 0L,orgId);
List<Permission> privileges = securityService.getMenuTree(sysUser.getId(), orgId);
if (Objects.isNull(privileges) || privileges.size() < 1) throw new UsernameNotFoundException("用户在组织上没有权限");
sysUser.setCurrentMenuList(privileges);
//加载用户组织ID及对应的角色
sysUser.setOrgIdMapRoleList(sysOrgIdMapSysRoleList);
//加载用户的组织对象列表
sysUser.setOrgList(sysOrgIdMapSysRoleList.keySet().stream().map(securityService::getOrgById).collect(Collectors.toSet()));
}
return sysUser;
}
}
//通过手机号码查找用户
public User retrieveSysUserByPhone(String phone) {
return securityDao.selectUserByPhone(phone);
}
User selectUserByPhone(String phone) {
String sql = "select * from general_sys_user where phone = ? and state=1";
Object[] objects = new Object[]{phone};
try {
return jdbcTemplate.queryForObject(sql, new BeanPropertyRowMapper<>(User.class), objects);
} catch (EmptyResultDataAccessException e) {
return null;
}
}
- 短信登陆鉴权 Provider,要求实现 AuthenticationProvider 接口
方法首先能够在使用短信验证码登陆时候被 AuthenticationManager 挑中,其次要在这个类中处理验证逻辑。
package com.lc.gansu.security.smsValidate;
import com.lc.gansu.security.component.SecurityConstants;
import org.springframework.data.redis.core.RedisTemplate;
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.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
/**
* 短信登陆鉴权 Provider,要求实现 AuthenticationProvider 接口
* 方法首先能够在使用短信验证码登陆时候被 AuthenticationManager 挑中,其次要在这个类中处理验证逻辑。
*
* 步骤:
*
* 实现 AuthenticationProvider 接口,实现 authenticate() 和 supports() 方法。
* supports() 方法决定了这个 Provider 要怎么被 AuthenticationManager 挑中,我这里通过 return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication),处理所有 SmsCodeAuthenticationToken 及其子类或子接口。
* authenticate() 方法处理验证逻辑。
* 首先将 authentication 强转为 SmsCodeAuthenticationToken。
* 从中取出登录的 principal,也就是手机号。
* 调用自己写的 checkSmsCode() 方法,进行验证码校验,如果不合法,抛出 AuthenticationException 异常。
* 如果此时仍然没有异常,通过调用 loadUserByUsername(mobile) 读取出数据库中的用户信息。
* 如果仍然能够成功读取,没有异常,这里验证就完成了。
* 重新构造鉴权后的 SmsCodeAuthenticationToken,并返回给 SmsCodeAuthenticationFilter 。
* SmsCodeAuthenticationFilter 的父类在 doFilter() 方法中处理是否有异常,是否成功,根据处理结果跳转到登录成功/失败逻辑。
*/
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private SmsUserDetailsService smsUserDetailsService;
private RedisTemplate redisTemplate;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
String mobile = (String) authenticationToken.getPrincipal();
checkSmsCode(mobile);
UserDetails userDetails = smsUserDetailsService.loadUserByUsername(mobile);
// 此时鉴权成功后,应当重新 new 一个拥有鉴权的 authenticationResult 返回
SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
private void checkSmsCode(String mobile) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String inputCode = request.getParameter("smsCode");
if (!redisTemplate.hasKey(SecurityConstants.getValidCodeKey(mobile))) {
throw new BadCredentialsException("验证码已过期");
}
String code = (String) redisTemplate.boundValueOps(SecurityConstants.getValidCodeKey(mobile)).get();
if (!code.equals(inputCode)) {
throw new BadCredentialsException("验证码错误");
}
}
@Override
public boolean supports(Class<?> authentication) {
// 判断 authentication 是不是 SmsCodeAuthenticationToken 的子类或子接口
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
public UserDetailsService getUserDetailsService() {
return smsUserDetailsService;
}
public void setUserDetailsService(SmsUserDetailsService smsUserDetailsService, RedisTemplate redisTemplate) {
this.smsUserDetailsService = smsUserDetailsService;
this.redisTemplate = redisTemplate;
}
}
8.配置短信鉴权过滤器琏(要加到系统的安全控件的过滤器琏里)
package com.lc.gansu.security.smsValidate;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lc.gansu.security.SecurityService;
import com.lc.gansu.security.component.CustomAuthenticationFailureHandler;
import com.lc.gansu.security.component.jwt.JwtAuthenticationSuccessHandler;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* TODO 配置短信鉴权过滤器琏(要加到系统的安全控件的过滤器琏里)
*/
@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Resource
private ObjectMapper jacksonObjectMapper;
@Resource
private SecurityService securityService;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Override
public void configure(HttpSecurity http) throws Exception {
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(new JwtAuthenticationSuccessHandler(jacksonObjectMapper, redisTemplate));
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(new CustomAuthenticationFailureHandler(jacksonObjectMapper));
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(new SmsUserDetailsService(securityService), redisTemplate);
http.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
9.成功失败的逻辑还走原来的JwtAuthenticationSuccessHandler和CustomAuthenticationFailureHandler
10.加到过滤器琏里面,放在原有登录逻辑之前
.and().apply(smsCodeAuthenticationSecurityConfig)