1、功能实现
Security默认提供的是用户名密码登录模式,然后我们参考用户名密码登录自定义实现短信登录模式
这样就多了一种登录模式,在登录的时候可以自行选择登录模式
2、security07 子工程
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.yzm</groupId>
<artifactId>security</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath> <!-- lookup parent from repository -->
</parent>
<artifactId>security07</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>security07</name>
<description>Demo project for Spring Boot</description>
<dependencies>
<dependency>
<groupId>com.yzm</groupId>
<artifactId>common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.192.128:3306/testdb?useUnicode=true&characterEncoding=utf8&useSSL=false&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull&serverTimezone=Asia/Shanghai
username: root
password: 1234
main:
allow-bean-definition-overriding: true
mybatis-plus:
mapper-locations: classpath:/mapper/*Mapper.xml
type-aliases-package: com.yzm.security07.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
3、短信认证过滤器
用户名密码的认证过滤器是 UsernamePasswordAuthenticationFilter
那么我们就仿照它实现用于短信认证的过滤器
package com.yzm.security07.config;
import org.springframework.lang.Nullable;
import org.springframework.security.authentication.AuthenticationManager;
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.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 短信认证过滤器
*/
public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
// form表单中手机号码的字段name
public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";
// 拦截/sms/login
private static final AntPathRequestMatcher DEFAULT_SMS_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/sms/login", "POST");
private String mobileParameter = "mobile";
private boolean postOnly = true;
public SmsAuthenticationFilter() {
super(DEFAULT_SMS_PATH_REQUEST_MATCHER);
}
public SmsAuthenticationFilter(AuthenticationManager authenticationManager) {
super(DEFAULT_SMS_PATH_REQUEST_MATCHER, authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String mobile = this.obtainMobile(request);
mobile = mobile != null ? mobile.trim() : "";
SmsAuthenticationToken authRequest = new SmsAuthenticationToken(mobile);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
@Nullable
protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(mobileParameter);
}
protected void setDetails(HttpServletRequest request, SmsAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
public void setMobileParameter(String mobileParameter) {
Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
this.mobileParameter = mobileParameter;
}
public String getMobileParameter() {
return this.mobileParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
}
对应的UsernamePasswordAuthenticationToken 改造成 SmsAuthenticationToken
package com.yzm.security07.config;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
/**
* 短信登录 AuthenticationToken
*/
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 554008100412847685L;
/**
* 在 UsernamePasswordAuthenticationToken 中该字段代表登录的用户名,
* 在这里就代表登录的手机号码
*/
private final Object principal;
/**
* 构建一个没有鉴权的 SmsCodeAuthenticationToken
*/
public SmsAuthenticationToken(Object principal) {
super(null);
this.principal = principal;
setAuthenticated(false);
}
/**
* 构建拥有鉴权的 SmsCodeAuthenticationToken
*/
public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
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);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
}
}
4、短信认证 Provider
自定义短信认证 Provider
把AbstractUserDetailsAuthenticationProvider跟其子类,DaoAuthenticationProvider的作用结合一起写了
package com.yzm.security07.config;
import com.yzm.common.utils.HttpUtils;
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 javax.servlet.http.HttpServletRequest;
import java.util.Map;
/**
* 短信认证 Provider
*/
public class SmsAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
public SmsAuthenticationProvider(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Override
public boolean supports(Class<?> authentication) {
// 判断 authentication 是不是 SmsCodeAuthenticationToken 的子类或子接口
return SmsAuthenticationToken.class.isAssignableFrom(authentication);
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsAuthenticationToken authenticationToken = (SmsAuthenticationToken) authentication;
String mobile = (String) authenticationToken.getPrincipal();
checkSmsCode(mobile);
// 相当于DaoAuthenticationProvider的retrieveUser()
UserDetails userDetails = userDetailsService.loadUserByUsername(mobile);
// 相当于AbstractUserDetailsAuthenticationProvider的createSuccessAuthentication()
SmsAuthenticationToken authenticationResult = new SmsAuthenticationToken(userDetails, userDetails.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
// 检查验证码
private void checkSmsCode(String mobile) {
HttpServletRequest request = HttpUtils.getHttpServletRequest();
String inputCode = request.getParameter("smsCode");
Map<String, Object> smsMap = (Map<String, Object>) request.getSession().getAttribute("smsCode");
if (smsMap == null) {
throw new BadCredentialsException("未检测到申请验证码");
}
String applyMobile = (String) smsMap.get("mobile");
int code = (int) smsMap.get("code");
if (!applyMobile.equals(mobile)) {
throw new BadCredentialsException("申请的手机号码与登录手机号码不一致");
}
if (code != Integer.parseInt(inputCode)) {
throw new BadCredentialsException("验证码错误");
}
}
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
protected UserDetailsService getUserDetailsService() {
return this.userDetailsService;
}
}
5、SecurityConfig 配置类
将自定义的SmsAuthenticationFilter添加到Security框架的过滤链中
package com.yzm.security07.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Slf4j
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final UserDetailsService userDetailsService;
public SecurityConfig(@Qualifier("secUserDetailsServiceImpl") UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
/**
* 密码编码器
* passwordEncoder.encode是用来加密的,passwordEncoder.matches是用来解密的
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 配置用户
* 指定默认从哪里获取认证用户的信息,即指定一个UserDetailsService接口的实现类
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 从数据库获取用户
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
/**
* 自定义SmsAuthenticationFilter需要注入AuthenticationManager,否则报空指针
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
//配置资源权限规则
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 关闭CSRF跨域
.csrf().disable()
// addFilterAfter 在过滤链中的指定Filter(第二个参数)之后,添加Filter
.addFilterAfter(new SmsAuthenticationFilter(authenticationManagerBean()), UsernamePasswordAuthenticationFilter.class)
.authenticationProvider(new SmsAuthenticationProvider(userDetailsService))
// 登录
.formLogin()
.loginPage("/auth/login") //指定登录页的路径,默认/login
.loginProcessingUrl("/login") //指定自定义form表单请求的路径(必须跟login.html中的form action=“url”一致)
.defaultSuccessUrl("/home", true) // 登录成功后的跳转url地址
.failureUrl("/auth/login?error") // 登录失败后的跳转url地址
.permitAll()
.and()
.exceptionHandling()
.accessDeniedPage("/401") // 拒接访问跳转页面
.and()
// 退出登录
.logout()
.permitAll()
.and()
// 访问路径URL的授权策略,如注册、登录免登录认证等
.authorizeRequests()
.antMatchers("/", "/home", "/register", "/auth/login").permitAll() //指定url放行
.antMatchers("/sms/code").permitAll() //获取短信验证码
.anyRequest().authenticated() //其他任何请求都需要身份认证
;
}
}
6、验证码接口
此次演示只是模拟手机短信登录,手机号既是用户名,验证码是随机数并存储到session中的
在HomeController新增接口
@GetMapping("/sms/code")
public String sms(String mobile, HttpSession session) {
int code = (int) Math.ceil(Math.random() * 9000 + 1000);
Map<String, Object> map = new HashMap<>(16);
map.put("mobile", mobile);
map.put("code", code);
session.setAttribute("smsCode", map);
log.info("{}:为 {} 设置短信验证码:{}", session.getId(), mobile, code);
return "redirect:/auth/login";
}
.antMatchers("/sms/code").permitAll() //获取短信验证码
7、登录页面
提供两种登录模式
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录页</title>
</head>
<body>
<h1 th:if="${param.error}">Invalid username or password.</h1>
<h2>用户名密码登录</h2>
<form action="/login" method="post">
<p>
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Username">
</p>
<p>
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Password">
</p>
<p>
<label>
<input type="checkbox" name="remember-me">
</label> Remember me on this computer.
</p>
<button type="submit">Sign in</button>
</form>
<h2>短信登录</h2>
<form method="post" action="/sms/login">
<div>
手机号:<label for="mobile"></label><input type="text" id="mobile" name="mobile" value="yzm">
</div>
<div>
验证码:<label><input type="text" name="smsCode"></label>
<a href="javascript:;" onclick="sendSms()">获取验证码</a>
</div>
<div>
<button type="submit">立即登陆</button>
</div>
</form>
<script>
function sendSms() {
window.location.href = '/sms/code?mobile=' + document.getElementById("mobile").value;
}
</script>
</body>
</html>
8、测试
启动项目,登录页
用户名密码登录yzm,没问题
退出/logout 换短信登录,先获取验证码
输入验证码进行登录,也是没问题的
9、短信认证流程
1 首先入口还是 AbstractAuthenticationProcessingFilter#doFilter
// addFilterAfter 在过滤链中的指定Filter(第二个参数)之后,添加Filter
.addFilterAfter(new SmsAuthenticationFilter(authenticationManagerBean()), UsernamePasswordAuthenticationFilter.class)
new AntPathRequestMatcher("/sms/login", "POST");
new AntPathRequestMatcher("/login", "POST");
这里我们是把短信认证放在用户名密码认证之后,
SmsAuthenticationFilter 拦截路径是/sms/login,而UsernamePasswordAuthenticationFilter是/login
短信登录发送请求/sms/login,
不是UsernamePasswordAuthenticationFilter需要拦截的,所以放行
是被我们自定义的SmsAuthenticationFilter拦截了,所以走了SmsAuthenticationFilter过滤器
2 AbstractAuthenticationProcessingFilter调用SmsAuthenticationFilter#attemptAuthentication()方法
3 SmsAuthenticationFilter构造未认证的SmsAuthenticationToken并提交给认证管理器AuthenticationManager
4 由认证管理器的子类ProviderManager遍历查找有没有支持SmsAuthenticationToken的AuthenticationProvider,发现我们自定义的SmsAuthenticationProvider支持
5 SmsAuthenticationProvider获取用户信息,并构造完全认证的Authentication对象返回
6 AbstractAuthenticationProcessingFilter接收的返回的完全认证Authentication对象之后,进行其他处理