前言
目前登录接口没有做任何约束,代表任何人都能够编写脚本的方法暴力破解,会形成安全问题,假如写一个循环一向尝试访问登录接口,那么服务器就一向会收到恳求,一次恳求代表一次查表,会给服务器形成很大的压力,本篇文章就来给登录接口增加一个验证码校验。
完成方法
先看一下结构关于登录流程的介绍 文档 文档 从这两张图能够看出恳求UsernamePasswordAuthenticationFilter之后会调用ProviderManager里的DaoAuthenticationProvider进行验证。
有两种方法能够完成给接口增加验证码校验的功用。
- 承继
DaoAuthenticationProvider
并重写authenticate
办法,办法内增加具体的校验逻辑,在办法最终调用父类的authenticate
完成。
文档中有说到DaoAuthenticationProvider
会运用UserDetailsService
和PasswordEncoder
去校验用户提交的账号暗码,所以在其逻辑履行之前增加校验验证码的逻辑即可。 - 编写一个过滤器,在过滤器中增加校验,然后在装备中将过滤器增加至过滤器链中,方位在
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="" alt="logo" >
<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">-->
<!-- 渠道登录-->
<!-- </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: 1px #5e72e4 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: 1px #e4e7ed 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: 1px #5e72e4 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);
测验
这次测验流程跟上一种办法相同,自己就不放图了,读者自测一下即可。
总结
本篇文章开篇阐明晰结构处理登录的流程,依据文档供给的流程图找到了处理登录的中心代码,找到中心代码之后就能够扩展自定义内容了;在增加扩展之前优化了登录接口与登录页面,增加图形验证码功用;最终供给了两种让结构校验用户提交的验证码的方法,这两种方法读者凭喜爱自选即可。