前言
目前登录接口没有做任何限制,代表任何人都可以编写脚本的方式暴力破解,会造成安全问题,如果写一个循环一直尝试访问登录接口,那么服务器就一直会收到请求,一次请求代表一次查表,会给服务器造成很大的压力,本篇文章就来给登录接口添加一个验证码校验。
实现方式
先看一下框架关于登录流程的介绍
文档
文档
从这两张图可以看出请求UsernamePasswordAuthenticationFilter之后会调用ProviderManager里的DaoAuthenticationProvider进行验证。
有两种方式可以实现给接口添加验证码校验的功能。
1、 继承DaoAuthenticationProvider
并重写authenticate
方法,方法内添加具体的校验逻辑,在方法最后调用父类的authenticate
实现;
文档中有提到DaoAuthenticationProvider
会使用UserDetailsService
和PasswordEncoder
去校验用户提交的账号密码,所以在其逻辑执行之前添加校验验证码的逻辑即可。
2、 编写一个过滤器,在过滤器中添加校验,然后在配置中将过滤器添加至过滤器链中,位置在UsernamePasswordAuthenticationFilter
之前;
添加校验逻辑前的准备
编写一个接口提供验证码
引入图形验证码生成工具
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-captcha</artifactId>
<version>5.8.18</version>
</dependency>
添加生成图形验证码的接口
在AuthorizationController中添加以下接口
@ResponseBody
@GetMapping("/getCaptcha")
public Map<String,Object> getCaptcha(HttpSession session) {
// 使用hutool-captcha生成图形验证码
// 定义图形验证码的长、宽、验证码字符数、干扰线宽度
ShearCaptcha captcha = CaptchaUtil.createShearCaptcha(150, 40, 4, 2);
// 这里应该返回一个统一响应类,暂时使用map代替
Map<String,Object> result = new HashMap<>();
result.put("code", HttpStatus.OK.value());
result.put("success", true);
result.put("message", "获取验证码成功.");
result.put("data", captcha.getImageBase64Data());
// 存入session中
session.setAttribute("captcha", captcha.getCode());
return result;
}
读者可以选择存入redis这种NoSql服务里
在过滤器链中放行接口
/**
* 配置认证相关的过滤器链
*
* @param http spring security核心配置类
* @return 过滤器链
* @throws Exception 抛出
*/
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authorize) -> authorize
// 放行静态资源
.requestMatchers("/assets/**", "/webjars/**", "/login", "/getCaptcha").permitAll()
.anyRequest().authenticated()
)
// 指定登录页面
.formLogin(formLogin ->
formLogin.loginPage("/login")
);
// 添加BearerTokenAuthenticationFilter,将认证服务当做一个资源服务,解析请求头中的token
http.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults())
.accessDeniedHandler(SecurityUtils::exceptionHandler)
.authenticationEntryPoint(SecurityUtils::exceptionHandler)
);
return http.build();
}
修改登录接口
修改登录接口,获取具体的异常信息,给页面一个友好的提示,如果接口不提供具体的异常则用户无法知道到底出了什么问题。
在上边的文档中有说明,如果登录失败会由AuthorizationFailureHandler
处理,前文也提到过默认的是SimpleUrlAuthenticationFailureHandler
;在UsernamePasswordAuthenticationFilter
父类AbstractAuthenticationProcessingFilter
中也有体现
一路追踪下来发现框架会将异常保存至request或者session中(默认是在session中),所以在登录接口中取出异常然后通过thymeleaf渲染页面时携带上异常信息
具体的修改内容
@GetMapping("/login")
public String login(Model model, HttpSession session) {
Object attribute = session.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
if (attribute instanceof AuthenticationException exception) {
model.addAttribute("error", exception.getMessage());
}
return "login";
}
在exception包中创建异常类
package com.example.exception;
import org.springframework.security.core.AuthenticationException;
/**
* 验证码异常类
* 校验验证码异常时抛出
*
* @author vains
*/
public class InvalidCaptchaException extends AuthenticationException {
public InvalidCaptchaException(String msg) {
super(msg);
}
}
校验验证码异常时抛出
修改登录页面,添加验证码输入框
优化登录页面
这是我之前写的一个响应式的登录页面
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport"
content="width=device-width, initial-scale=1 minimum-scale=1 maximum-scale=1 user-scalable=no" />
<link rel="stylesheet" href="./assets/css/style.css" type="text/css" />
<title>统一认证平台</title>
</head>
<body>
<div class="bottom-container">
</div>
<!-- <div th:if="${error}" class="alert" id="alert">
<div class="error-alert">
<img src="./image/logo.png" alt="logo" width="30">
<div th:text="${error}">
</div>
</div>
</div> -->
<div id="error_box">
</div>
<div class="form-container">
<form class="form-signin" method="post" th:action="@{/login}">
<!-- <div th:if="${param.error}" class="alert alert-danger" role="alert" th:text="${param}">
Invalid username or password.
</div>
<div th:if="${param.logout}" class="alert alert-success" role="alert">
你已经登出成功.
</div> -->
<!-- <div class="text-placeholder" style="padding-bottom: 20px;">-->
<!-- 平台登录-->
<!-- </div>-->
<div class="welcome-text">
<img src="./assets/img/logo.png" alt="logo" width="60">
<span>
统一认证平台
</span>
</div>
<div>
<input type="text" id="username" name="username" class="form-control" placeholder="手机 / 邮箱" required
autofocus onblur="leave()" />
</div>
<div>
<input type="password" id="password" name="password" class="form-control" placeholder="请输入密码" required
onblur="leave()" />
</div>
<div class="code-container">
<input type="text" id="code" name="code" class="form-control" placeholder="请输入验证码" required
onblur="leave()" />
<img src="" id="code-image" onclick="getVerifyCode()" />
</div>
<button class="btn btn-lg btn-primary btn-block" type="submit">登 录</button>
</form>
</div>
</body>
</html>
<script>
function leave() {
document.body.scrollTop = document.documentElement.scrollTop = 0;
}
function getVerifyCode() {
let requestOptions = {
method: 'GET',
redirect: 'follow'
};
fetch(${ window.location.origin}/getCaptcha, requestOptions)
.then(response => response.text())
.then(r => {
if (r) {
let result = JSON.parse(r);
document.getElementById('code-image').src = result.data
}
})
.catch(error => console.log('error', error));
}
getVerifyCode();
</script>
<script th:inline="javascript">
function showError(message) {
let errorBox = document.getElementById("error_box");
errorBox.innerHTML = message;
errorBox.style.display = "block";
}
function closeError() {
let errorBox = document.getElementById("error_box");
errorBox.style.display = "none";
}
let error = [[${
error }]]
if (error) {
if (window.Notification) {
Notification.requestPermission(function () {
if (Notification.permission === 'granted') {
// 用户点击了允许
let n = new Notification('登录失败', {
body: error,
icon: './assets/img/logo.png'
})
setTimeout(() => {
n.close();
}, 3000)
} else {
showError(error);
setTimeout(() => {
closeError();
}, 3000)
}
})
}
}
</script>
添加css文件和图片
在static\assets\css目录下添加style.css
* {
margin: 0;
padding: 0;
}
body {
height: 100vh;
overflow: hidden;
background: linear-gradient(200deg,72afd3,96fbc4);
}
/* 上方欢迎语 */
.welcome-text {
color: black;
display: flex;
font-size: 18px;
font-weight: 300;
line-height: 1.7;
align-items: center;
justify-content: center;
}
.welcome-text img {
margin-right: 12px !important;
}
/* 提示文字 */
.text-placeholder {
display: flex;
font-size: 80%;
color:909399;
justify-content: center;
}
/* 下方背景颜色 */
.bottom-container {
width: 100%;
height: 50vh;
bottom: -15vh;
position: absolute;
transform: skew(0, 3deg);
background: rgb(23, 43, 77);
}
/* 表单卡片样式 */
.form-container {
width: 100vw;
display: flex;
height: 100vh;
align-items: center;
justify-content: center;
}
/* 表单样式 */
.form-signin {
z-index: 20;
width: 25vw;
display: flex;
border-radius: 3%;
padding: 35px 50px;
flex-direction: column;
background: rgb(247, 250, 252);
}
/* 按钮样式 */
.btn-primary {
height: 40px;
color: white;
cursor: pointer;
border-radius: 0.25rem;
background:5e72e4;
border: 1px5e72e4 solid;
transition: all 0.15s ease;
/* -webkit-box-shadow: 0 4px 6px rgb(50 50 93 / 11%), 0 1px 3px rgb(0 0 0 / 8%);
box-shadow: 0 4px 6px rgb(50 50 93 / 11%), 0 1px 3px rgb(0 0 0 / 8%); */
}
.btn-primary:hover {
transform: translateY(-3%);
-webkit-box-shadow: 0 4px 6px rgb(50 50 93 / 11%), 0 1px 3px rgb(0 0 0 / 8%);
box-shadow: 0 4px 6px rgb(50 50 93 / 11%), 0 1px 3px rgb(0 0 0 / 8%);
}
/* 表单间距 */
.form-signin div, button {
margin-bottom: 25px;
}
/* 表单输入框 */
.form-signin input {
width: 100%;
height: 40px;
outline: none;
text-indent: 15px;
border-radius: 3px;
border: 1pxe4e7ed solid;
}
/* 表单验证码容器 */
.code-container {
display: flex;
justify-content: space-between;
}
/* 表单验证码容器 */
.code-container input {
margin-right: 10px;
}
#code-image {
width: 150px;
height: 40px;
}
/* 表单超链接 */
.btn-light {
height: 40px;
display: flex;
color:5e72e4;
border-radius: 3px;
align-items: center;
justify-content: center;
border: 1px5e72e4 solid;
}
.form-signin img {
margin: 0;
}
.form-signin a {
text-decoration: none;
}
.btn-light:hover {
transform: translateY(-3%);
-webkit-box-shadow: 0 4px 6px rgb(50 50 93 / 11%), 0 1px 3px rgb(0 0 0 / 8%);
box-shadow: 0 4px 6px rgb(50 50 93 / 11%), 0 1px 3px rgb(0 0 0 / 8%);
}
.form-signin input:focus {
border: 1px solid rgb(41, 50, 225);
}
.alert {
top: 20px;
width: 100%;
z-index: 50;
display: flex;
position: absolute;
align-items: center;
justify-content: center;
}
/* 弹框样式 */
#error_box {
background-color: rgba(0, 0, 0, 0.7);
color:fff;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 10px;
padding: 15px;
display: none;
z-index: 500;
animation: shake 0.2s;
}
#error_message {
padding-left: 10px;
}
@keyframes shake {
0% {
transform: translate(-50%, -50%);
}
25% {
transform: translate(-45%, -50%);
}
50% {
transform: translate(-50%, -50%);
}
75% {
transform: translate(-45%, -50%);
}
100% {
transform: translate(-50%, -50%);
}
}
/*修改提示信息的文本颜色*/
input::-webkit-input-placeholder {
/* WebKit browsers */
color:8898aa;
}
input::-moz-placeholder {
/* Mozilla Firefox 19+ */
color:8898aa;
}
input:-ms-input-placeholder {
/* Internet Explorer 10+ */
color:8898aa;
}
/* 移动端css */
@media screen and (orientation: portrait) {
.form-signin {
width: 100%;
}
.form-container {
width: auto;
height: 90vh;
padding: 20px;
}
.welcome-text {
top: 9vh;
flex-direction: column;
}
}
/* 宽度 */
/* 屏幕 > 666px && < 800px */
@media (min-width: 667px) and (max-width: 800px) {
.form-signin {
width: 50vw;
}
.welcome-text {
top: 18vh;
}
}
/* 屏幕 > 800px */
@media (min-width: 800px) and (max-width: 1000px) {
.form-signin {
width: 500px;
}
}
/* 高度 */
@media (min-height: 600px) and (max-height: 600px) {
.welcome-text {
top: 6%;
}
}
@media (min-height: 800px) and (max-height: 1000px) {
.welcome-text {
top: 12%;
}
}
在static\assets\img文件夹下添加log.png图片(图片自选,我这里只是一个示例)
编写验证码校验逻辑
编写provider替换DaoAuthenticationProvider
package com.example.authorization;
import com.example.exception.InvalidCaptchaException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 验证码校验
* 注入ioc中替换原先的DaoAuthenticationProvider
* 在authenticate方法中添加校验验证码的逻辑
* 最后调用父类的authenticate方法并返回
*
* @author vains
*/
@Slf4j
@Component
public class CaptchaAuthenticationProvider extends DaoAuthenticationProvider {
/**
* 利用构造方法在通过{@link Component}注解初始化时
* 注入UserDetailsService和passwordEncoder,然后
* 设置调用父类关于这两个属性的set方法设置进去
*
* @param userDetailsService 用户服务,给框架提供用户信息
* @param passwordEncoder 密码解析器,用于加密和校验密码
*/
public CaptchaAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
super.setPasswordEncoder(passwordEncoder);
super.setUserDetailsService(userDetailsService);
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
log.info("Authenticate captcha...");
// 获取当前request
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) {
throw new InvalidCaptchaException("Failed to get the current request.");
}
HttpServletRequest request = ((ServletRequestAttributes)requestAttributes).getRequest();
// 获取参数中的验证码
String code = request.getParameter("code");
if (ObjectUtils.isEmpty(code)) {
throw new InvalidCaptchaException("The captcha cannot be empty.");
}
// 获取session中存储的验证码
Object sessionCaptcha = request.getSession(Boolean.FALSE).getAttribute("captcha");
if (sessionCaptcha instanceof String sessionCode) {
if (!sessionCode.equalsIgnoreCase(code)) {
throw new InvalidCaptchaException("The captcha is incorrect.");
}
} else {
throw new InvalidCaptchaException("The captcha is abnormal. Obtain it again.");
}
log.info("Captcha authenticated.");
return super.authenticate(authentication);
}
}
测试
启动项目,访问接口让项目将请求重定向至登录页
输入错误的验证码提交后会提示验证码错误
验证码输入正确提交后正常执行登录流程,查看控制台日志,提示验证成功。
2023-06-09T09:48:32.209+08:00 INFO 112092 --- [nio-8080-exec-3] c.e.a.CaptchaAuthenticationProvider : Authenticate captcha...
2023-06-09T09:48:32.209+08:00 INFO 112092 --- [nio-8080-exec-3] c.e.a.CaptchaAuthenticationProvider : Captcha authenticated.
编写过滤器并将其添加在UsernamePasswordAuthenticationFilter之前
先去掉刚才添加的验证码校验,屏蔽Component注解
在filter包下添加CaptchaAuthenticationFilter并继承GenericFilterBean
package com.example.filter;
import com.example.exception.InvalidCaptchaException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.web.filter.GenericFilterBean;
import java.io.IOException;
/**
* 验证码校验过滤器
*
* @author vains
*/
@Slf4j
public class CaptchaAuthenticationFilter extends GenericFilterBean {
private AuthenticationFailureHandler failureHandler;
private final RequestMatcher requiresAuthenticationRequestMatcher;
/**
* 初始化该过滤器,设置拦截的地址
*
* @param defaultFilterProcessesUrl 拦截的地址
*/
public CaptchaAuthenticationFilter(String defaultFilterProcessesUrl) {
Assert.hasText(defaultFilterProcessesUrl, "defaultFilterProcessesUrl cannot be null.");
requiresAuthenticationRequestMatcher = new AntPathRequestMatcher(defaultFilterProcessesUrl);
failureHandler = new SimpleUrlAuthenticationFailureHandler(defaultFilterProcessesUrl + "?error");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// 检验是否是post请求并且是需要拦截的地址
if (!this.requiresAuthenticationRequestMatcher.matches(request) || !request.getMethod().equals(HttpMethod.POST.toString())) {
chain.doFilter(request, response);
return;
}
// 开始校验验证码
log.info("Authenticate captcha...");
// 获取参数中的验证码
String code = request.getParameter("code");
try {
if (ObjectUtils.isEmpty(code)) {
throw new InvalidCaptchaException("The captcha cannot be empty.");
}
// 获取session中存储的验证码
Object sessionCaptcha = request.getSession(Boolean.FALSE).getAttribute("captcha");
if (sessionCaptcha instanceof String sessionCode) {
if (!sessionCode.equalsIgnoreCase(code)) {
throw new InvalidCaptchaException("The captcha is incorrect.");
}
} else {
throw new InvalidCaptchaException("The captcha is abnormal. Obtain it again.");
}
} catch (AuthenticationException ex) {
this.failureHandler.onAuthenticationFailure(request, response, ex);
return;
}
log.info("Captcha authenticated.");
// 验证码校验通过开始执行接下来的逻辑
chain.doFilter(request, response);
}
public void setAuthenticationFailureHandler(AuthenticationFailureHandler failureHandler) {
Assert.notNull(failureHandler, "failureHandler cannot be null");
this.failureHandler = failureHandler;
}
}
该过滤器的请求拦截和异常处理借鉴了AbstractAuthenticationProcessingFilter,中间添加校验验证码的逻辑
添加至身份认证过滤器链中
/**
* 配置认证相关的过滤器链
*
* @param http spring security核心配置类
* @return 过滤器链
* @throws Exception 抛出
*/
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authorize) -> authorize
// 放行静态资源
.requestMatchers("/assets/**", "/webjars/**", "/login", "/getCaptcha").permitAll()
.anyRequest().authenticated()
)
// 指定登录页面
.formLogin(formLogin ->
formLogin.loginPage("/login")
);
// 在UsernamePasswordAuthenticationFilter拦截器之前添加验证码校验拦截器,并拦截POST的登录接口
http.addFilterBefore(new CaptchaAuthenticationFilter("/login"), UsernamePasswordAuthenticationFilter.class);
// 添加BearerTokenAuthenticationFilter,将认证服务当做一个资源服务,解析请求头中的token
http.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults())
.accessDeniedHandler(SecurityUtils::exceptionHandler)
.authenticationEntryPoint(SecurityUtils::exceptionHandler)
);
return http.build();
}
主要是这一行,将过滤器添加至过滤器链中
// 在UsernamePasswordAuthenticationFilter拦截器之前添加验证码校验拦截器,并拦截POST的登录接口
http.addFilterBefore(new CaptchaAuthenticationFilter("/login"), UsernamePasswordAuthenticationFilter.class);
测试
这次测试流程跟上一种方法一样,本人就不放图了,读者自测一下即可。
总结
本篇文章开篇说明了框架处理登录的流程,根据文档提供的流程图找到了处理登录的核心代码,找到核心代码之后就可以扩展自定义内容了;在添加扩展之前优化了登录接口与登录页面,添加图形验证码功能;最后提供了两种让框架校验用户提交的验证码的方式,这两种方式读者凭喜好自选即可。