Security + Spring boot + jwt 多方式登录(图片验证码,手机验证码,邮箱验证码)
Security认证流程图
通过自定义UsernamePasswordAuthenticationFilter和BasicAuthenticationFilter 实现(验证码验证可以在UsernamePasswordAuthenticationFilter之前再加入一个Filter过滤器来处理)
使用原来的登录url (/login)
JWTLoginFilter
/**
* 自定义JWT登录过滤器
* 验证用户名密码正确后,生成一个token,并将token返回给客户端
* 该类继承自UsernamePasswordAuthenticationFilter,重写了其中的3个方法
* attemptAuthentication :接收并解析用户凭证。
* successfulAuthentication :用户成功登录后,这个方法会被调用,我们在这个方法里生成token。
* unsuccessfulAuthentication : 认证失败,异常往外抛,让全局异常捕捉(当然你也可以判断异常类型,返回不同的code)
* @author cola
*/
public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
public JWTLoginFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
// 尝试身份认证(接收并解析用户凭证)
@Override
public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) throws AuthenticationException {
try {
SysLoginForm loginForm = new ObjectMapper().readValue(req.getInputStream(), SysLoginForm.class);
if(!ClassUtils.equalStringPropertyValue(LoginModeConstant.class,loginForm.getLoginMode())){
logger.error("未选择登录方式");
throw new AuthenticationServiceException("服务异常");
}
checkVerificationCode(loginForm);
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginForm.getUsername(),
loginForm.getPassword(),
new ArrayList<>())
);
} catch (IOException | IllegalAccessException | InstantiationException e) {
throw new RuntimeException(e);
}
}
protected void checkVerificationCode(SysLoginForm loginForm) throws GalaxyException{
RedisUtils redisUtils = (RedisUtils) SpringContextUtils.getBean("redisUtils");
String key = null;
String value = null;
if(LoginModeConstant.PHONE_VERIFICATION_CODE.equals(loginForm.getLoginMode())){
key = RedisKeys.PHONE_VERIFICATION_LOGIN_PREFIX + loginForm.getPhone();
value = redisUtils.get(key);
if(StrUtil.isBlank(value) || !value.equals(loginForm.getPhoneVerificationCode())){
throw new AuthenticationServiceException("验证码错误");
}
}else if(LoginModeConstant.EMAIL_VERIFICATION_CODE.equals(loginForm.getLoginMode())){
key = RedisKeys.EMAIL_VERIFICATION_LOGIN_PREFIX + loginForm.getEmail();
value = redisUtils.get(key);
if(StrUtil.isBlank(value) || !value.equals(loginForm.getEmailVerificationCode())){
throw new AuthenticationServiceException("验证码错误");
}
}else {
key = RedisKeys.IMAGE_VERIFICATION_LOGIN_PREFIX + loginForm.getUuid();
value = redisUtils.get(RedisKeys.IMAGE_VERIFICATION_LOGIN_PREFIX + loginForm.getUuid());
if(StrUtil.isBlank(value) || !value.equals(loginForm.getImageVerificationCode())){
throw new AuthenticationServiceException("验证码错误");
}
}
redisUtils.delete(key);
}
// 认证成功(用户成功登录后,这个方法会被调用,我们在这个方法里生成token)
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication auth) throws IOException, ServletException {
// builder the token
String token = null;
try {
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
// 定义存放角色集合的对象
List roleList = new ArrayList<>();
for (GrantedAuthority grantedAuthority : authorities) {
roleList.add(grantedAuthority.getAuthority());
}
JwtConfig jwtConfig = (JwtConfig) SpringContextUtils.getBean("jwtConfig");
// 生成token start
Calendar calendar = Calendar.getInstance();
// 签发时间
Date now = calendar.getTime();
// 过期时间
Date time = new Date(now.getTime() + jwtConfig.getExpiration());
token = Jwts.builder()
.setSubject(auth.getName() + "-" + roleList)
.setIssuedAt(now)//签发时间
.setExpiration(time)//过期时间
.signWith(SignatureAlgorithm.HS256, jwtConfig.getSecret()) //采用什么算法是可以自己选择的,不一定非要采用HS512
.compact();
// 生成token end
// 登录成功后,返回token
TokenInfoDTO dto = new TokenInfoDTO();
dto.setToken(token);
dto.setUserName(auth.getName());
response.setCharacterEncoding("utf-8");
response.setContentType("application/json; charset=utf-8");
response.getWriter().write(JSON.toJSONString(DataResultUtil.success(dto)));
logger.debug("认证成功");
logger.debug("用户:" + auth.getName());
} catch (Exception e) {
logger.error("认证异常",e);
response.getWriter().write(JSON.toJSONString(DataResultUtil.error("网络异常")));
}
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException, ServletException {
logger.debug("认证失败");
logger.debug(authenticationException.getMessage());
response.setCharacterEncoding("utf-8");
response.setContentType("application/json; charset=utf-8");
response.getWriter().write(JSON.toJSONString(DataResultUtil.error(authenticationException.getMessage())));
}
}
Security 登录异常不能全局捕捉,通过 unsuccessfulAuthentication 捕捉并返回,其实在这个地方再往外抛异常好像是可以,一开始我是这样做的,后来也失效了。就改成这样了,功力不够啊,求大神解惑。
CustomAuthenticationProvider
/**
* 自定义身份认证验证组件
*
* @author
*/
public class CustomAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
private BCryptPasswordEncoder bCryptPasswordEncoder;
public CustomAuthenticationProvider(UserDetailsService userDetailsService, BCryptPasswordEncoder bCryptPasswordEncoder){
this.userDetailsService = userDetailsService;
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
}
/**
*执行与以下合同相同的身份验证
* {@link org.springframework.security.authentication.AuthenticationManager#authenticate(Authentication)}
*。
*
* @param authentication 身份验证请求对象。
*
* @返回包含凭证的经过完全认证的对象。 可能会回来
* <code> null </ code>(如果<code> AuthenticationProvider </ code>无法支持)
* 对传递的<code> Authentication </ code>对象的身份验证。 在这种情况下,
* 支持所提供的下一个<code> AuthenticationProvider </ code>
* 将尝试<code> Authentication </ code>类。
*
* @throws AuthenticationException 如果身份验证失败。
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 获取认证的用户名 & 密码
String username = authentication.getName();
//明文密码
String password = authentication.getCredentials().toString();
// 认证逻辑
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (null != userDetails) {
//密码比对
if (bCryptPasswordEncoder.matches(password, userDetails.getPassword())) {
//根据用户的名查询用户的权限
ArrayList<GrantedAuthority> authorities = new ArrayList<>();
Set<String> permissions = SecurityUserUtils.getUserPermissions(username);
if(CollUtil.isNotEmpty(permissions)) {
for (String permission : permissions) {
authorities.add(new GrantedAuthorityServiceImpl(permission));
}
}
return new UsernamePasswordAuthenticationToken(username, password, authorities);
} else {
throw new BadCredentialsException("密码错误");
}
} else {
throw new UsernameNotFoundException("用户不存在");
}
}
/**
* 是否可以提供输入类型的认证服务
* @param authentication
* @return
*/
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
UserDetailsServiceImpl
/**
* @author cola
* @version 1.0
**/
@Service("userDetailsServiceImpl")
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
SysUserDao sysUserDao;
//根据 账号查询用户信息
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//将来连接数据库根据账号查询用户信息
SysUserEntity sysUserEntity = sysUserDao.queryByUserName(username);
if(sysUserEntity == null){
//如果用户查不到,返回null,由provider来抛出异常
return null;
}
// 交给 CustomAuthenticationProvider 处理认证,这里先不查询权限标识
return User.withUsername(sysUserEntity.getUsername()).password(sysUserEntity.getPassword()).authorities(emptyList()).build();
}
}
LoginModeConstant
public class LoginModeConstant {
/**
* @author: cola
* @date: 2020/11/30 14:21
* @description:图片验证码
*/
public static final String PHONE_VERIFICATION_CODE = "PHONE_VERIFICATION_CODE";
/**
* @author: cola
* @date: 2020/11/30 14:21
* @description:手机验证码
*/
public static final String IMAGE_VERIFICATION_CODE = "IMAGE_VERIFICATION_CODE";
/**
* @author: cola
* @date: 2020/11/30 14:21
* @description:邮箱验证码
*/
public static final String EMAIL_VERIFICATION_CODE = "EMAIL_VERIFICATION_CODE";
}
JWTAuthenticationFilter
/**
* 自定义JWT认证过滤器
* 该类继承自BasicAuthenticationFilter,在doFilterInternal方法中,
* 从http头或参数 项读取token数据,然后用Jwts包提供的方法校验token的合法性。
* 如果校验通过,就认为这是一个取得授权的合法请求
* @author cola
*/
public class JWTAuthenticationFilter extends BasicAuthenticationFilter {
private static final Logger logger = LoggerFactory.getLogger(JWTAuthenticationFilter.class);
public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = request.getHeader("token");
if (StringUtil.isNullOrEmpty(token)) {
token = request.getParameter("token");
if(StringUtil.isNullOrEmpty(token)) {
chain.doFilter(request, response);
return;
}
}
UsernamePasswordAuthenticationToken authentication = getAuthentication(request, response);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request, HttpServletResponse response) {
long start = System.currentTimeMillis();
String token = request.getHeader("token");
if (StringUtil.isNullOrEmpty(token)) {
token = request.getParameter("token");
if(StringUtil.isNullOrEmpty(token)) {
throw new TokenException("Token为空");
}
}
// parse the token.
String user = null;
try {
JwtConfig jwtConfig = (JwtConfig)SpringContextUtils.getBean("jwtConfig");
Claims claims = Jwts.parser().setSigningKey(jwtConfig.getSecret()).parseClaimsJws(token).getBody();
// token签发时间
long issuedAt = claims.getIssuedAt().getTime();
// 当前时间
long currentTimeMillis = System.currentTimeMillis();
// token过期时间
long expirationTime = claims.getExpiration().getTime();
// 1. 签发时间 < 当前时间 < (签发时间+((token过期时间-token签发时间)/2)) 不刷新token
// 2. (签发时间+((token过期时间-token签发时间)/2)) < 当前时间 < token过期时间 刷新token并返回给前端
// 3. tokne过期时间 < 当前时间 跳转登录,重新登录获取token
// 验证token时间有效性
if ((issuedAt + ((expirationTime - issuedAt) / 2)) < currentTimeMillis && currentTimeMillis < expirationTime) {
// 重新生成token start
Calendar calendar = Calendar.getInstance();
// 签发时间
Date now = calendar.getTime();
// 过期时间
Date time = new Date(now.getTime() + jwtConfig.getExpiration());
String refreshToken = Jwts.builder()
.setSubject(claims.getSubject())
.setIssuedAt(now)//签发时间
.setExpiration(time)//过期时间
.signWith(SignatureAlgorithm.HS256, jwtConfig.getSecret()) //采用什么算法是可以自己选择的,不一定非要采用HS512
.compact();
// 重新生成token end
// 主动刷新token,并返回给前端
response.addHeader("refreshToken", refreshToken);
}
long end = System.currentTimeMillis();
logger.debug("执行时间: {}", (end - start) + " 毫秒");
user = claims.getSubject();
if (user != null) {
String[] split = user.split("-")[1].split(",");
String userName = user.split("-")[0];
ArrayList<GrantedAuthority> authorities = new ArrayList<>();
for (String s : split) {
authorities.add(new GrantedAuthorityServiceImpl(s));
}
return new UsernamePasswordAuthenticationToken(userName, null, authorities);
}
} catch (ExpiredJwtException e) {
logger.error("Token已过期:",e);
throw new TokenException("Token已过期");
} catch (UnsupportedJwtException e) {
logger.error("Token格式错误:",e);
throw new TokenException("Token格式错误");
} catch (MalformedJwtException e) {
logger.error("Token没有被正确构造:",e);
throw new TokenException("Token没有被正确构造");
} catch (SignatureException e) {
logger.error("签名失败:",e);
throw new TokenException("签名失败");
} catch (IllegalArgumentException e) {
logger.error("非法参数异常:",e);
throw new TokenException("非法参数异常");
}
return null;
}
}
WebSecurityConfig
/**
* SpringSecurity的配置
* 通过SpringSecurity的配置,将JWTLoginFilter,JWTAuthenticationFilter组合在一起
* @author zhaoxinguo on 2017/9/13.
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 需要放行的URL
*/
@Value("#{'${auth.whitelist}'.split(',')}")
private String[] whitelist;
@Value("${auth.loginUrl}")
private String loginUrl;
@Value("${auth.logoutUrl}")
private String logoutUrl;
@Value("${auth.logoutSuccessUrl}")
private String logoutSuccessUrl;
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Autowired
private CustomAccessDeniedHandler customAccessDeniedHandler;
@Autowired
private CustomLogoutSuccessHandler customLogoutSuccessHandler;
// 设置 HTTP 验证规则
@Override
protected void configure(HttpSecurity http) throws Exception {
LogoutConfigurer<HttpSecurity> httpSecurityLogoutConfigurer = http
.formLogin()
.loginProcessingUrl(loginUrl)
.and()
.cors()
.and()
.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests().antMatchers(whitelist).permitAll().anyRequest().authenticated() // 所有请求需要身份认证
.and()
// .exceptionHandling().authenticationEntryPoint(new Http401AuthenticationEntryPoint("Basic realm=\"MyApp\""))
// .and()
// .exceptionHandling().accessDeniedHandler(customAccessDeniedHandler) // 自定义访问失败处理器
// .and()
.addFilter(new JWTLoginFilter(authenticationManager()))
.addFilter(new JWTAuthenticationFilter(authenticationManager()))
.logout() // 默认注销行为为logout,可以通过下面的方式来修改
.logoutUrl(logoutUrl)
.logoutSuccessUrl(logoutSuccessUrl)// 设置注销成功后跳转页面,默认是跳转到登录页面;
// .logoutSuccessHandler(customLogoutSuccessHandler)
.permitAll();
}
// 该方法是登录的时候会进入
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
// 使用自定义身份验证组件
auth.authenticationProvider(new CustomAuthenticationProvider(userDetailsService, new BCryptPasswordEncoder()));
}
}
GrantedAuthorityServiceImpl
/**
* 权限类型,负责存储权限和角色
*
* @author cola
*/
public class GrantedAuthorityServiceImpl implements GrantedAuthority {
private String authority;
public GrantedAuthorityServiceImpl(String authority) {
this.authority = authority;
}
public void setAuthority(String authority) {
this.authority = authority;
}
@Override
public String getAuthority() {
return this.authority;
}
}
TokenException
public class TokenException extends RuntimeException {
private static final long serialVersionUID = 1L;
private String msg;
private String code = ResultEnum.UNAUTHORIZED.getValue();
public TokenException(String msg) {
super(msg);
this.msg = msg;
}
public TokenException(String msg, Throwable e) {
super(msg, e);
this.msg = msg;
}
public TokenException(String msg, String code) {
super(msg);
this.msg = msg;
this.code = code;
}
public TokenException(String msg, String code, Throwable e) {
super(msg, e);
this.msg = msg;
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
}
配置信息
auth:
#白名单
whitelist: /v2/**,/swagger-ui.html,/swagger-resources/**,/doc.html,/webjars/**,/login,/captcha.jpg,/sms/login/code,/mail/login/code
#登录url
loginUrl: /login
#登出url
logoutUrl: /logout
#登出成功跳转url
logoutSuccessUrl: /login
其实实现很简单,复制代码,再修改一下报错就行(一些个性化的东西)jwt、redis集成可以自行百度。
jwt集成参考的是 springboot-springsecurity-jwt-demo
项目逻辑引用(jwt实现原理)
一: RestApi接口增加JWT认证功能
用户填入用户名密码后,与数据库里存储的用户信息进行比对,如果通过,则认证成功。传统的方法是在认证通过后,创建sesstion,并给客户端返回cookie。
现在我们采用JWT来处理用户名密码的认证。区别在于,认证通过后,服务器生成一个token,将token返回给客户端,客户端以后的所有请求都需要在http头中指定该token。
服务器接收的请求后,会对token的合法性进行验证。验证的内容包括:
内容是一个正确的JWT格式
检查签名
检查claims
检查权限
处理登录
创建一个类JWTLoginFilter,核心功能是在验证用户名密码正确后,生成一个token,并将token返回给客户端:
该类继承自UsernamePasswordAuthenticationFilter,重写了其中的2个方法:
attemptAuthentication :接收并解析用户凭证。
successfulAuthentication :用户成功登录后,这个方法会被调用,我们在这个方法里生成token。
二:授权验证
用户一旦登录成功后,会拿到token,后续的请求都会带着这个token,服务端会验证token的合法性。
创建JwtAuthenticationFilter类,我们在这个类中实现token的校验功能。
该类继承自BasicAuthenticationFilter,在doFilterInternal方法中,从http头的Authorization 项读取token数据,然后用Jwts包提供的方法校验token的合法性。
如果校验通过,就认为这是一个取得授权的合法请求。
三:SpringSecurity配置
通过SpringSecurity的配置,将上面的方法组合在一起。
这是标准的SpringSecurity配置内容,就不在详细说明。注意其中的
.addFilter(new JWTLoginFilter(authenticationManager()))
.addFilter(new JwtAuthenticationFilter(authenticationManager()))
这两行,将我们定义的JWT方法加入SpringSecurity的处理流程中。