1、简介
在日常工作生活中,系统安全无疑是至关重要的,平时使用的微信微博知乎都与其密切相关;Spring security就是为解决这一类问题而诞生的;Spring Security 是一个功能强大且高度可定制的身份验证和访问控制框架,与所有 Spring 项目一样,Spring Security 的真正强大之处在于它可以轻松扩展以满足自定义需求,提供身份验证、授权和针对常见攻击的保护;认证即确认用户身份,授权即给用户分配权限
注意:Spring Security 需要 Java 8 或更高版本的运行时环境。
2、快速开始
2.1 生成初始工程
访问springboot脚手架,Project勾选maven,Language勾选Java,SpringBoot版本号根据需求勾选,我这边选择2.7.11版本开发示例,Metadata里填入工程坐标、描述、打包方式以及Java版本号,Dependencies里添加spring-web和spring-security依赖,然后点击生成即可下载初始工程代码
2.2 创建私有接口
将初始工程导入到IDEA中,并创建私有接口http://localhost:8080/hello,启动服务并在浏览器中访问该接口
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(){
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String name = authentication.getName();
return "hello! " + name;
}
}
2.3 访问私有接口
浏览器跳转至登录接口http://localhost:8080/login,在From表单中输入用户名密码,默认用户名为user,密码已打印在服务启动日志中
Using generated security password: 76f9a55a-5328-49b6-aa75-55053f80198a
2.4 登录页面
输入用户名密码并点击登录,密码错误页面弹出”用户名或密码错误“,密码正确页面展示接口返回信息
3、工作原理
3.1 表单登录步骤
- 用户未经授权的资源发出未经身份验证的请求。
- FilterSecurityInterceptor作为认证方案的入口,检测到私有接口未经身份验证,抛出AccessDeniedException
- ExceptionTranslationFilter捕获认证异常,使用配置的AuthenticationEntryPoint(实现类LoginUrlAuthenticationEntryPoint)开启特定的认证流程
- 浏览器根据认证流程将请求重定向至登录页面
- 登录成功服务端将请求结果返回至浏览器
3.2 表单登录原理
- 用户提交用户名和密码时,在UsernamePasswordAuthenticationFilte中提取用户名和密码创建UsernamePasswordAuthenticationToken
- 将UsernamePasswordAuthenticationToken传递到AuthenticationManager中进行身份验证
- 如果身份验证失败,SecurityContextHolder被清除,RememberMeServices.loginFail被调用,AuthenticationFailureHandler被调用
- 如果身份验证成功,SessionAuthenticationStrategy收到新登录的通知,在SecurityContextHolder上设置身份验证,RememberMeServices.loginSuccess被调用,ApplicationEventPublisher发布一个InteractiveAuthenticationSuccessEvent,AuthenticationSuccessHandler被调用
4、 自定义表单登录
4.1 自定义登录页面
创建Spring security配置类BrowserSecurityConfig,继承抽象类WebSecurityConfigurerAdapter,重写方法configure(HttpSecurity http)如下
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin(withDefaults());
}
在此配置中,Spring Security将呈现默认登录页面,大多数生产应用程序都需要自定义登录表单
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/login")
.and().authorizeRequests()
.antMatchers("/login.html").permitAll()
.anyRequest().authenticated()
.and().csrf().disable();
}
- formLogin:指定表单登录
- loginPage:指定自定义登录页面
- loginProcessingUrl:指定登录接口
- authorizeRequests:授权配置
- antMatchers("/login.html").permitAll():所有匹配到的资源都允许访问
- anyRequest().authenticated():所有请求全部需要认证
- csrf().disable():关闭CSRF攻击防御
在工程src/main/resources/static目录下自定义一个login.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<form class="login-page" action="/login" method="post">
<div class="form">
<h3>账户登录</h3>
<input type="text" placeholder="用户名" name="username" required="required"/>
<input type="password" placeholder="密码" name="password" required="required"/>
<button type="submit">登录</button>
</div>
</form>
</body>
</html>
在controller中新增/login接口
@GetMapping("/login")
public String login(){
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String name = authentication.getName();
return name + " login";
}
启动服务并在浏览器中访问私有接口http://localhost:8080/hello,页面被重定向至自定义登录页面http://localhost:8080/login.html
输入正确的用户名密码,页面跳转至http://localhost:8080/hello
4.2 自定义登录成功/失败处理
创建认证成功处理器MyAuthenticationSuccessHandler,实现AuthenticationSuccessHandler接口,用户登录成功重定向至登录成功页
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("登录成功");
response.sendRedirect("/success.html");
}
}
在工程src/main/resources/static目录下自定义一个success.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>登录成功</title>
</head>
<body>
<h3>登录成功</h3>
</body>
</html>
创建认证失败处理器MyAuthenticationFailureHandler,实现AuthenticationFailureHandler接口,用户登录失败返回异常信息
@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
System.out.println("登录失败");
response.setStatus(UNAUTHORIZED);
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(exception.getMessage());
}
}
修改Spring security配置类BrowserSecurityConfig,注入authenticationSuccessHandler和authenticationFailureHandler,并讲其加入到HttpSecurity中
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/login")
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.authorizeRequests()
.antMatchers("/login.html").permitAll()
.anyRequest().authenticated()
.and().csrf().disable();;
}
启动服务并输入正确用户名和密码,登录成功并跳转至登录成功页面success.html
输入错误用户名和密码,登录失败并返回异常信息
4.3 自定义用户名密码
创建配置类SecurityConfig,并创建Bean对象密码编码器passwordEncoder
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
创建用户实体,字段分别为用户名、密码、账户未过期、账户未锁定、证书未过期、账号是否可用
public class MyUser {
private String userName;
private String password;
private boolean accountNonExpired = true;
private boolean accountNonLocked= true;
private boolean credentialsNonExpired= true;
private boolean enabled= true;
// 省略get/set方法,建议使用lombok的@Data注解
}
创建UserDetailService类,实现UserDetailsService接口,并重写【通过用户名查询用户信息】loadUserByUsername方法;这里模拟从数据库中通过用户名查询用户信息;在此省略用户校验;用户名随意,密码为123456,权限为admin
@Service
public class UserDetailService implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
MyUser user = getDataFromDB(username);
// 省略用户校验
return new User(username,
user.getPassword(),
user.isEnabled(),
user.isAccountNonExpired(),
user.isCredentialsNonExpired(),
user.isAccountNonLocked(),
AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
// 模拟通过用户名从数据库中获取用户信息
private MyUser getDataFromDB(String username) {
String password = this.passwordEncoder.encode("123456");
MyUser user = new MyUser();
user.setUserName(username);
user.setPassword(password);
return user;
}
}
启动服务控制台不再打印密码,在浏览器访问登录页面http://localhost:8080/login.html,用户名输入fsk,密码输入123,提示错误信息“用户名或密码错误”;密码输入123456,页面跳转至登录成功页面
4.4 用户登出
在登录成功业success.html新增登出按钮,请求接口为/logout,请求方式为POST
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>登录成功</title>
</head>
<body>
<h3>登录成功</h3>
<form class="logout-page" action="/logout" method="post">
<div class="form">
<button type="submit">登出</button>
</div>
</form>
</body>
</html>
创建登出成功处理器MyLogoutSuccessHandler,并实现LogoutSuccessHandler接口,登出成功之后重定向至登录页面
@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("登出成功");
response.sendRedirect("/login.html");
}
}
在配置类BrowserSecurityConfig添加登出配置,登出接口默认值为/logout,如果需要自定义登出接口,需修改logoutUrl和login.html中的登出接口,并创建自定义登出接口
@Autowired
private LogoutSuccessHandler logoutSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/login")
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler(logoutSuccessHandler)
.and()
.authorizeRequests()
.antMatchers("/login.html").permitAll()
.anyRequest().authenticated()
.and().csrf().disable();;
}
启动服务,在浏览器中访问登录页面http://localhost:8080/login.html,输入正确用户名密码,页面跳转至登录成功页
点击登出按钮,页面跳转至登录页面,同时控制台打印登出成功
4.5 记住我
Spring security登录网页支持“记住我”功能,勾选“记住我”一段时间内,用户无需再次登录便可访问系统资源;具体工作流程如下
- 用户登录成功之后勾选记住我,spring security会生成一串token
- Spring security将其持久化到数据库中,同时生成一个与之对应的cookie返回至浏览器端存储
- 用户下次访问系统时,如果浏览器端cookie未过期,便可以通过cookie找到与之对应的token,自动完成登录操作
这里需要用到数据库,因此需要做如下操作
在pom.xml文件中加入mysql数据库相关依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.21</version>
</dependency>
在application.properties文件中加入数据库相关配置
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.url=jdbc:mysql://127.0.0.1:3306/security?useUnicode=yes&characterEncoding=UTF-8&useSSL=false spring.datasource.username=root spring.datasource.password=123456
创建token持久化表persistent_logins,建表语句为JdbcTokenRepositoryImpl类中的常量CREATE_TABLE_SQL
create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null)";
在配置类SecurityConfig中创建Bean对象令牌持久化仓库persistentTokenRepository
@Autowired
private DataSource dataSource;
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
jdbcTokenRepository.setCreateTableOnStartup(false);
return jdbcTokenRepository;
}
在配置类BrowserSecurityConfig中,HttpSecurity新增rememberMe相关配置,tokenRepository指定token的持久化仓库,tokenValiditySeconds指定cookie在浏览器的过期时间(单位为秒),userDetailService处理自动登录逻辑
@Autowired
private UserDetailService userDetailService;
@Autowired
private PersistentTokenRepository persistentTokenRepository;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/login")
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler(logoutSuccessHandler)
.and()
.authorizeRequests()
.antMatchers("/login.html").permitAll()
.anyRequest().authenticated()
.and()
.rememberMe()
.tokenRepository(persistentTokenRepository)
.tokenValiditySeconds(3600)
.userDetailsService(userDetailService)
.and().csrf().disable();
}
在登录页面中新增勾选框“记住我”
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<form class="login-page" action="/login" method="post">
<div class="form">
<h3>账户登录</h3>
<input type="text" placeholder="用户名" name="username" required="required"/>
<input type="password" placeholder="密码" name="password" required="required"/>
<input type="checkbox" name="remember-me"/> 记住我
<button type="submit">登录</button>
</div>
</form>
</body>
</html>
启动服务并访问登录页面,可以看到登录按钮后面有一个勾选框“记住我”,输入正确的用户名密码,并勾选“记住我”,点击登录按钮
进入登录成功页面
在登录成功页面按F12或点击右键进入检查页面,在Application的cookie中可以看到Name为“remember-me”的记录
persistent_logins表中新增一条记录
tokenValiditySeconds配置为3600,即在登录成功之后一小时以内,无需再次登录就可访问服务端私有接口;在登录成功页面点击登出按钮,页面跳转至登录页面,cookie中“remember-me”将被一同删除
4.6 图形验证码
图形验证码在日常生活中应用广泛,其验证流程大致可以分成以下5步:
- 用户访问登录页面时加载"验证码获取接口"
- 认证服务器接收请求生成验证码、图形验证码和uuid,将uuid和验证码对应关系存储在缓存中,将uuid和图形验证码返回至页面
- 页面接收到uuid和图形验证码,展示图形验证码,并将uuid保存至localStorage,
- 用户提交用户请求时,向认证服务器发送用户名密码、验证码和uuid
- 认证服务器根据请求参数uuid查找与之对应的验证码,将其与请求参数验证码进行比较,成功则继续执行后续密码校验逻辑
在pom.xml文件中添加hutool依赖,后面需要用到其图形验证码生成工具类和本地超时缓存工具类
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.12</version>
</dependency>
创建本地超时缓存类LocalCache,存储uuid和验证码的对应关系,缓存过期时间单位为毫秒;生产实践中会用分布式缓存Redis来代替
public class LocalCache {
private static TimedCache<String, String> cache = CacheUtil.newTimedCache(60 * 1000);
public static TimedCache<String, String> getCache() {
return cache;
}
}
创建"验证码获取接口"的返回实体类ImageCode,返回参数分别是图形验证码字符串和uuid
public class ImageCode {
private String img;
private String uuid;
// 省略get/set
}
在控制器中创建"验证码获取接口",接口路径为/code/image;主要做以下操作,刷新操作时删除历史验证码,生成uuid存储到缓存中,将uuid和图形验证码返回至前端页面
@GetMapping("/code/image")
public ImageCode createCode(String uuid) throws IOException {
if (StrUtil.isNotEmpty(uuid)) {
LocalCache.getCache().remove(uuid);
}
CircleCaptcha captcha = CaptchaUtil.createCircleCaptcha(100, 50, 4, 10);
String newUuid = UUID.randomUUID().toString();
LocalCache.getCache().put(newUuid, captcha.getCode());
ImageCode imageCode = new ImageCode();
imageCode.setImg("data:image/gif;base64," + captcha.getImageBase64());
imageCode.setUuid(newUuid);
return imageCode;
}
在配置类BrowserSecurityConfig中将"验证码获取接口"/code/image配置成允许访问
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/login")
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler(logoutSuccessHandler)
.and()
.authorizeRequests()
.antMatchers("/login.html","/code/image").permitAll()
.anyRequest().authenticated()
.and()
.rememberMe()
.tokenRepository(persistentTokenRepository)
.tokenValiditySeconds(3600)
.userDetailsService(userDetailService)
.and().csrf().disable();
}
在登录页面的
标签中新增onload事件,即进入页面立即加载getCodeImage方法,方法首先调用认证服务器的/code/image接口获取图形验证码和uuid,然后将图形验证码展示到Form的标签中,在标签中添加点击事件来支持验证码刷新,将uuid存储到本地内存localStorage中; 在登录表单中新增验证码输入框和uuid隐藏框,在登录提交时会将这两个参数与用户名密码一同提交给认证服务器
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body onload="getCodeImage()">
<form id="login-form" class="login-page" action="/login" method="post">
<div class="form">
<h3>账户登录</h3>
<input type="text" placeholder="用户名" name="username" required="required"/><br>
<input type="password" placeholder="密码" name="password" required="required"/><br>
<input type="text" placeholder="验证码" name="code" required="required"/>
<input type="hidden" id="uuid" name="uuid" required="required"/>
<img onclick="getCodeImage()" id="image_code"><br>
<input type="checkbox" name="remember-me"/>记住我<br>
<button type="button" onclick="login()">登录</button>
</div>
</form>
</body>
<script>
function getCodeImage() {
var xhr = new XMLHttpRequest();
xhr.open('GET', "/code/image?uuid=" + localStorage.getItem('uuid'), true);
xhr.send();
xhr.onreadystatechange = function () {
if (xhr.status === 200 && xhr.readyState === 4) {
document.getElementById("image_code").src = JSON.parse(xhr.responseText).img
localStorage.setItem('uuid', JSON.parse(xhr.responseText).uuid)
}
}
}
function login() {
document.getElementById("uuid").value = (localStorage.getItem('uuid'))
document.getElementById("login-form").submit()
}
</script>
</html>
自定义验证码异常类,继承认证异常类AuthenticationException
public class ValidateCodeException extends AuthenticationException {
public ValidateCodeException(String message) {
super(message);
}
}
创建验证码过滤器ValidateCodeFilter,继承过滤器OncePerRequestFilter;它会拦截所有用户请求,当请求接口为登录接口/login并且请求方式为post时,将会对该请求进行验证码校验,依次校验验证码是否为空、验证码是否过期和验证码是否正确;校验通过则继续后续流程,否则抛出对应异常信息
@Component
public class ValidateCodeFilter extends OncePerRequestFilter {
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if ("/login".equalsIgnoreCase(request.getRequestURI()) && "post".equalsIgnoreCase(request.getMethod())) {
try {
validateCode(request);
} catch (ValidateCodeException e) {
authenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
filterChain.doFilter(request, response);
}
private void validateCode(HttpServletRequest request) {
String code = request.getParameter("code");
String uuid = request.getParameter("uuid");
String localCode = LocalCache.getCache().get(uuid);
if (StrUtil.isEmpty(code.trim())) {
throw new ValidateCodeException("验证码不能为空");
}
if (StrUtil.isEmpty(localCode)) {
throw new ValidateCodeException("验证码已过期");
}
if (!code.equalsIgnoreCase(localCode)) {
throw new ValidateCodeException("验证码不正确!");
}
LocalCache.getCache().remove(uuid);
}
}
在配置类BrowserSecurityConfig中,将验证码过滤器加入到HttpSecurity中,且位于用户名密码过滤器UsernamePasswordAuthenticationFilter之前,验证码过滤器必须先于用户名密码过滤器之前执行
@Autowired
private ValidateCodeFilter validateCodeFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin()
...
}
启动服务并访问登录页面,此时可以看到验证码输入框和其右侧图形验证码图片,点击图形验证码进行刷新
在浏览器页面输入F12或者右键检查,在application的Local Storage中可以看到key为uuid的记录,刷新图形验证码uuid值发生变更
依次输入空验证码、过期验证码(刷新之前的验证码或者一分钟之前获取的验证码)和错误验证码,页面会报出以下错误信息;输入正确用户名密码及验证码,页面会跳转至登录成功页面
4.7 手机号登录
手机号登录也是一种常见的登录方式,其流程大致可分为以下三个步骤:
- 获取短信验证码
- 验证短信验证码
- 手机号登录
在登录页面新增登录表单和验证码发送方法
<form class="login-page" action="/login/phone" method="post">
<div class="form">
<h3>短信验证码登录</h3>
<input type="text" id="phone" placeholder="手机号" name="phone" required="required"/><br>
<input type="text" name="smsCode" placeholder="短信验证码"/>
<a href="javascript:void(0)" onclick="sendSms()">发送验证码</a></br>
<button type="submit">登录</button>
</div>
</form>
function sendSms() {
var xhr = new XMLHttpRequest();
xhr.open('GET', "/code/sms?phone=" + document.getElementById("phone").value, true);
xhr.send();
xhr.onreadystatechange = function () {
if (xhr.status === 200 && xhr.readyState === 4) {
}
}
}
在认证服务器端新增短信验证码发送接口,生成短信验证码保存至本地缓存,并调用短信服务向指定手机号发送验证码
@GetMapping("/code/sms")
public void createSmsCode(String phone) throws IOException {
if (StrUtil.isEmpty(phone)){
throw new ValidateCodeException("手机号不能为空");
}
String smsCode = RandomUtil.randomNumbers(4);
LocalCache.getCache().put(phone, smsCode);
System.out.println(StrUtil.format("向手机号{}发送验证码{}", phone, smsCode));
}
新增短信验证码过滤器SmsCodeFilter并将其添加到用户名密码过滤器UsernamePasswordAuthenticationFilter之后,用以校验短信验证码的有效性,这里与图形验证码过滤器校验逻辑类似
public class SmsCodeFilter extends OncePerRequestFilter {
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if ("/login/phone".equals(request.getRequestURI()) && "post".equalsIgnoreCase(request.getMethod())) {
try {
validateCode(request);
} catch (ValidateCodeException e) {
authenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
filterChain.doFilter(request, response);
}
private void validateCode(HttpServletRequest request) {
String phone = request.getParameter("phone");
String smsCode = request.getParameter("smsCode");
String localSmsCode = LocalCache.getCache().get(phone);
if (StrUtil.isEmpty(smsCode.trim())) {
throw new ValidateCodeException("验证码不能为空");
}
if (StrUtil.isEmpty(localSmsCode)) {
throw new ValidateCodeException("验证码已过期");
}
if (!smsCode.equals(localSmsCode)) {
throw new ValidateCodeException("验证码不正确");
}
LocalCache.getCache().remove(phone);
}
}
@Autowired
private SmsCodeFilter smsCodeFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(smsCodeFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin()
...
}
手机号登录逻辑仿照用户名密码登录,其中主要涉及四个类SmsAuthenticationToken、SmsAuthenticationFilter、SmsAuthenticationProvider和SmsAuthenticationConfig;第一个类短信验证令牌SmsAuthenticationToken对应于用户名密码验证令牌UsernamePasswordAuthenticationToken,都继承抽象类AbstractAuthenticationToken ;它是对用户登录信息的封装,以便在身份验证提供者AuthenticationProvider实现类中进行扭转验证;认证成功将父类AbstractAuthenticationToken中的authenticated属性字段更新为true
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
private Object principal;
public SmsAuthenticationToken(String phone){
super(null);
this.principal = phone;
}
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;
}
}
短信认证过滤器SmsAuthenticationFilter对应于用户名密码过滤器UsernamePasswordAuthenticationFilter,都其主要作用是将用户登录信息封装成认证令牌AuthenticationToken,并将其传给ProviderManager类中,供AuthenticationProvider进行验证
public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public SmsAuthenticationFilter() {
super(new AntPathRequestMatcher("/login/phone", "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
if (!"POST".equals(request.getMethod())) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String phoneNo = request.getParameter("phone");
SmsAuthenticationToken authenticationToken = new SmsAuthenticationToken(phoneNo);
authenticationToken.setDetails(authenticationDetailsSource.buildDetails(request));
return this.getAuthenticationManager().authenticate(authenticationToken);
}
}
短信身份验证提供者SmsAuthenticationProvider对应于数据库身份验证提供者DaoAuthenticationProvider,都实现认证提供者接口AuthenticationProvider;SmsAuthenticationProvider重写了AuthenticationProvider中的supports和authenticate,其中supports方法指定了SmsAuthenticationProvider支持处理的AuthenticationToken类型,authenticate方法负责处理具体的身份认证逻辑;改造UserDetailService类中的loadUserByUsername方法,使其支持提过手机号查询用户信息
public class SmsAuthenticationProvider implements AuthenticationProvider {
private UserDetailService userDetailService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsAuthenticationToken authenticationToken = (SmsAuthenticationToken) authentication;
UserDetails userDetails = userDetailService.loadUserByUsername((String) authenticationToken.getPrincipal());
if (userDetails == null){
throw new InternalAuthenticationServiceException("未找到与该手机号对应的用户");
}
SmsAuthenticationToken authenticationResult = new SmsAuthenticationToken(userDetails, userDetails.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
@Override
public boolean supports(Class<?> authentication) {
return SmsAuthenticationToken.class.isAssignableFrom(authentication);
}
// 省略get/set方法
}
创建短信认证配置类SmsAuthenticationConfig;将短信认证提供者smsAuthenticationProvider加入到认证提供者列表中,将短信认证过滤器smsAuthenticationFilter加入到过滤器链中,这里是加入到用户名密码认证过滤器UsernamePasswordAuthenticationFilter之后,这两个过滤器可以不分先后
@Component
public class SmsAuthenticationConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Autowired
private UserDetailService userDetailService;
@Override
public void configure(HttpSecurity http) throws Exception {
SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter();
smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
smsAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider();
smsAuthenticationProvider.setUserDetailService(userDetailService);
http.authenticationProvider(smsAuthenticationProvider)
.addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
将短信认证配置类SmsAuthenticationConfig应用到HttpSecurity中
@Autowired
private SmsAuthenticationConfig smsAuthenticationConfig;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
...
.and().csrf().disable()
.apply(smsAuthenticationConfig);
}
Spring Security的认证逻辑大致如下,下面代码截取自ProviderManager类,Spring Security维护了一个身份认证提供者列表;每次进行身份验证时,程序会将认证令牌AuthenticationToken依次传入AuthenticationProvider中进行身份验证,只要AuthenticationToken在其中某一个AuthenticationProvider中验证成功,则跳出循环,并更新AuthenticationToken中authenticated字段为true,验证成功
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
Iterator var9 = this.getProviders().iterator();
while(var9.hasNext()) {
AuthenticationProvider provider = (AuthenticationProvider)var9.next();
if (provider.supports(toTest)) {
try {
result = provider.authenticate(authentication);
if (result != null) {
this.copyDetails(authentication, result);
break;
}
} catch (InternalAuthenticationServiceException | AccountStatusException var14) {
this.prepareException(var14, authentication);
throw var14;
} catch (AuthenticationException var15) {
lastException = var15;
}
}
}
}
4.8 权限管理
在配置类BrowserSecurityConfig中添加注解EnableGlobalMethodSecurity开启权限控制
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {}
新增访问异常处理器MyAccessDeniedHandler
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write("很抱歉,您没有该访问权限");
}
}
在HttpSecurity中指定访问异常处理器AccessDeniedHandler
@Autowired
private AccessDeniedHandler accessDeniedHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
...
.and().exceptionHandling()
.accessDeniedHandler(accessDeniedHandler);
改造loadUserByUsername方法,登录用户名为fsk分配admin权限,否则分配test权限
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
MyUser user = getDataFromDB(username);
List<GrantedAuthority> authorities;
if ("fsk".equals(username)) {
authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("admin");
} else {
authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("test");
}
return new User(username,
user.getPassword(),
user.isEnabled(),
user.isAccountNonExpired(),
user.isCredentialsNonExpired(),
user.isAccountNonLocked(),
authorities);
}
在控制器中创建两个接口,/auth/admin接口需要admin权限才能服务,/auth/test接口需要test接口才能访问
@GetMapping("/auth/admin")
@PreAuthorize("hasAuthority('admin')")
public String authAdmin() {
return "您拥有admin权限,可以查看";
}
@GetMapping("/auth/test")
@PreAuthorize("hasAuthority('test')")
public String authTest() {
return "您拥有test权限,可以查看";
}
启动服务并用fsk用户登录
登录成功之后直接访问/auth/admin接口,请求正常返回
访问/auth/test接口,请求被拒绝并返回异常信息