强烈建议在学习完 2.x 版本的配置流程之后再阅读本文
推荐一个:视频教程
数据库操作部分省略了
<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>3.0.0version>
<relativePath/>
parent>
<dependencies>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.springframework.sessiongroupId>
<artifactId>spring-session-data-redisartifactId>
dependency>
<dependency>
<groupId>com.github.pengglegroupId>
<artifactId>kaptchaartifactId>
<version>2.3.2version>
dependency>
<dependency>
<groupId>org.springdocgroupId>
<artifactId>springdoc-openapi-starter-webmvc-uiartifactId>
<version>2.0.0version>
dependency>
<dependency>
<groupId>com.github.xiaoymingroupId>
<artifactId>knife4j-springdoc-uiartifactId>
<version>3.0.3version>
dependency>
dependencies>
注:结尾包含了 springdoc+knife4j 生成接口文档,示例代码中也包含了springdoc提供的注解。
生成配置(与视频教程中一致)
@Configuration
public class KaptchaConfig {
@Bean
public Producer kaptcha() {
final Properties properties = new Properties();
//高度
properties.setProperty("kaptcha.image.width", "150");
//宽度
properties.setProperty("kaptcha.image.height", "50");
//可选字符串
properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
//验证码长度
properties.setProperty("kaptcha.textproducer.char.length", "4");
final DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(new Config(properties));
return defaultKaptcha;
}
}
接口
生成验证码,保存到Session
的Attribute
中,后续验证时也从这里取出,两个接口返回不同格式的验证码数据。
@Controller
@RequestMapping("/sys/verifyCode")
@RequiredArgsConstructor
@Tag(name = "验证码接口")
public class VerifyCodeController {
public static final String VERIFY_CODE_KEY = "vc";
private final Producer producer;
@GetMapping("/base64")
@Operation(summary = "Base64格式")
@ResponseBody
public Res<String> base64(@Parameter(hidden = true) HttpSession httpSession) throws IOException {
//生成验证码
final BufferedImage image = createImage(httpSession);
//响应图片
final FastByteArrayOutputStream os = new FastByteArrayOutputStream();
ImageIO.write(image, "jpeg", os);
//返回 base64
return Res.of(Base64.encodeBase64String(os.toByteArray()));
}
@GetMapping("/image")
@Operation(summary = "图片格式")
public void image(@Parameter(hidden = true) HttpServletResponse response, @Parameter(hidden = true) HttpSession httpSession) throws IOException {
final BufferedImage image = createImage(httpSession);
//响应图片
response.setContentType(MimeTypeUtils.IMAGE_JPEG_VALUE);
ImageIO.write(image, "jpeg", response.getOutputStream());
}
private BufferedImage createImage(HttpSession httpSession) {
//生成验证码
final String verifyCode = producer.createText();
//保存到 session 中(或redis中)
httpSession.setAttribute(VERIFY_CODE_KEY, verifyCode);
//生成图片
return producer.createImage(verifyCode);
}
}
SystemUserService
提供数据库访问接口UserDetailsPasswordService
,SpringSecurity
如果发现用户的密码加密方法过时或明文,将会自动修改密码。createUser
方法是调用了SpringSecurity
提供的User.UserBuilder
构造了一个UserDetails
@Service
直接注册到容器@Service
@RequiredArgsConstructor
public class MyUserDetailsServiceImpl implements UserDetailsService, UserDetailsPasswordService {
private final SystemUserService systemUserService;
/**
* 当前用户
* @return 当前用户
*/
public SystemUser currentUser() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
final String username = ((UserDetails) authentication.getPrincipal()).getUsername();
return systemUserService.getByUsername(username);
}
/**
* 根据用户名查询用户的认证授权信息
* @param username 用户名
* @return org.springframework.security.core.userdetails.UserDetails
* @throws UsernameNotFoundException 异常
* @since 2022/12/6 15:03
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
final SystemUser systemUser = systemUserService.getByUsername(username);
if (systemUser == null) {
throw new UsernameNotFoundException("用户不存在");
}
return systemUser.createUser()
.authorities(new ArrayList<>())
.build();
}
/**
* 修改密码
* @param user 用户
* @param newPassword 新密码
* @return UserDetails
*/
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
final SystemUser systemUser = systemUserService.getByUsername(user.getUsername());
systemUser.setPassword(newPassword);
systemUserService.updateById(systemUser);
return systemUser.createUser()
.authorities(new ArrayList<>())
.build();
}
}
因为这些接口里都只有一个方法,且都需要类似的处理,我把它集中到一起实现,流程几乎是一致的:
Content-Type
为application/json;charset=UTF-8
response
唯一需要注意的地方是,登陆成功后需要清理已使用过的验证码
注意:我们有两个地方需要用到这个对象,所以直接注册到容器中方便注入
@Component
public class MyAuthenticationHandler implements AuthenticationSuccessHandler
, AuthenticationFailureHandler
, LogoutSuccessHandler
, SessionInformationExpiredStrategy
, AccessDeniedHandler, AuthenticationEntryPoint {
public static final String APPLICATION_JSON_CHARSET_UTF_8 = "application/json;charset=UTF-8";
public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/**
* 认证失败处理
* @param request that resulted in an AuthenticationException
* @param response so that the user agent can begin authentication
* @param authException that caused the invocation
* @throws IOException 异常
* @throws ServletException 异常
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
String detailMessage = e.getClass().getSimpleName() + " " + e.getLocalizedMessage();
if (e instanceof InsufficientAuthenticationException) {
detailMessage = "请登陆后再访问";
}
response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(detailMessage, "认证异常")));
}
/**
* 权限不足时的处理
* @param request that resulted in an AccessDeniedException
* @param response so that the user agent can be advised of the failure
* @param accessDeniedException that caused the invocation
*/
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
String detailMessage = null;
if (accessDeniedException instanceof MissingCsrfTokenException) {
detailMessage = "缺少CSRF TOKEN,请从表单或HEADER传入";
} else if (accessDeniedException instanceof InvalidCsrfTokenException) {
detailMessage = "无效的CSRF TOKEN";
} else if (accessDeniedException instanceof CsrfException) {
detailMessage = accessDeniedException.getLocalizedMessage();
} else if (accessDeniedException instanceof AuthorizationServiceException) {
detailMessage = AuthorizationServiceException.class.getSimpleName() + " " + accessDeniedException.getLocalizedMessage();
}
response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
response.setStatus(HttpStatus.FORBIDDEN.value());
response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(detailMessage, "禁止访问")));
}
/**
* 认证失败时的处理
*/
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(exception.getLocalizedMessage(), "登陆失败")));
}
/**
* 认证成功时的处理
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
response.setStatus(HttpStatus.OK.value());
// SecurityContext在设置Authentication的时候并不会自动写入Session,读的时候却会根据Session判断,所以需要手动写入一次,否则下一次刷新时SecurityContext是新创建的实例。
// https://yangruoyu.blog.csdn.net/article/details/128276473
request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, SecurityContextHolder.getContext());
response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(MyUserDetails.of(authentication), "登陆成功")));
//清理使用过的验证码
request.getSession().removeAttribute(VERIFY_CODE_KEY);
}
/**
* 会话过期处理
* @throws IOException 异常
* @throws ServletException 异常
*/
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
String message = "该账号已从其他设备登陆,如果不是您自己的操作请及时修改密码";
final HttpServletResponse response = event.getResponse();
response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(event.getSessionInformation(), message)));
}
/**
* 登出成功处理
* @param request 请求
* @param response 响应
* @param authentication 认证信息
* @throws IOException 异常
* @throws ServletException 异常
*/
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
response.setStatus(HttpStatus.OK.value());
response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(MyUserDetails.of(authentication), "注销成功")));
}
}
记住我功能,规定了:
request
的Attribute
中获取rememberMe
字段TRUE_VALUES
表的成员时认为需要开启记住我功能构造函数中
PersistentTokenRepository
会在后续提供UserDetailsService
已在前文提供注意:我们有两个地方需要用到这个对象,所以直接注册到容器中方便注入
@Component
public class MyRememberMeServices extends PersistentTokenBasedRememberMeServices {
public static final String REMEMBER_ME_KEY = "rememberMe";
public static final List<String> TRUE_VALUES = List.of("true", "yes", "on", "1");
public MyRememberMeServices(UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) {
super(UUID.randomUUID().toString(), userDetailsService, tokenRepository);
}
@Override
protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
final String rememberMe = (String) request.getAttribute(REMEMBER_ME_KEY);
if (rememberMe != null) {
for (String trueValue : TRUE_VALUES) {
if (trueValue.equalsIgnoreCase(rememberMe)) {
return true;
}
}
}
return super.rememberMeRequested(request, parameter);
}
}
UsernamePasswordAuthenticationFilter
,后续我们要用它替换默认的UsernamePasswordAuthenticationFilter
attemptAuthentication
方法中规定了登陆流程:
Content-Type
是Json,则从Body
中获取请求参数,否则从Form表单
中获取Session
的Attribute
中获取之前保存的验证码,和用户提供的验证码进行比对rememberMe
字段放到request
的Attribute
中,供后续MyRememberMeServices
获取@Component
public class MyLoginFilter extends UsernamePasswordAuthenticationFilter {
private final ObjectMapper objectMapper = new ObjectMapper();
public MyLoginFilter(AuthenticationManager authenticationManager,
MyAuthenticationHandler authenticationHandler,
MyRememberMeServices rememberMeServices) throws Exception {
super(authenticationManager);
setAuthenticationFailureHandler(authenticationHandler);
setAuthenticationSuccessHandler(authenticationHandler);
//rememberMe
setRememberMeServices(rememberMeServices);
//登陆使用的路径
setFilterProcessesUrl("/sys/user/login");
}
private static boolean isContentTypeJson(HttpServletRequest request) {
final String contentType = request.getContentType();
return APPLICATION_JSON_CHARSET_UTF_8.equalsIgnoreCase(contentType) || MimeTypeUtils.APPLICATION_JSON_VALUE.equalsIgnoreCase(contentType);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!HttpMethod.POST.name().equalsIgnoreCase(request.getMethod())) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = null;
String password = null;
String verifyCode = null;
String rememberMe = null;
if (isContentTypeJson(request)) {
try {
Map<String, String> map = objectMapper.readValue(request.getInputStream(), new TypeReference<>() {
});
username = map.get(getUsernameParameter());
password = map.get(getPasswordParameter());
verifyCode = map.get(VERIFY_CODE_KEY);
rememberMe = map.get(MyRememberMeServices.REMEMBER_ME_KEY);
} catch (IOException e) {
e.printStackTrace();
}
} else {
username = obtainUsername(request);
password = obtainPassword(request);
verifyCode = request.getParameter(VERIFY_CODE_KEY);
rememberMe = request.getParameter(MyRememberMeServices.REMEMBER_ME_KEY);
}
//校验验证码
final String vc = (String) request.getSession().getAttribute(VERIFY_CODE_KEY);
if (vc == null) {
throw new BadCredentialsException("验证码不存在,请先获取验证码");
} else if (verifyCode == null || "".equals(verifyCode)) {
throw new BadCredentialsException("请输入验证码");
} else if (!vc.equalsIgnoreCase(verifyCode)) {
throw new BadCredentialsException("验证码错误");
}
//将 rememberMe 状态存入 attr中
if (!ObjectUtils.isEmpty(rememberMe)) {
request.setAttribute(MyRememberMeServices.REMEMBER_ME_KEY, rememberMe);
}
username = (username != null) ? username.trim() : "";
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
@Bean authenticationManager
提供了MyLoginFilter
需要的AuthenticationManager
@Bean daoAuthenticationProvider
提供了MyRememberMeServices
需要的PersistentTokenRepository
,其中setCreateTableOnStartup
方法在首次运行的时候需要解开注释让它自动建表@Bean securityFilterChain
核心中的核心,2.x版本中对HttpSecurity http
的配置都需要移动到这里,这里我们配置了:
MyLoginFilter
替换了默认的UsernamePasswordAuthenticationFilter
,注意原本的http.formLogin()
不要再写了,否则将可以通过/login
绕过验证码登陆.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
sessionRegistry
了,会自动注入,当然手动配置也是可以的,但是容器里不会自动创建了,需要手动传一个new SpringSessionBackedSessionRegistry<>(new RedisIndexedSessionRepository(redisTemplate))
,其中redisTemplate
需要为RedisTemplate
MyLoginFilter
里的两次配置缺一不可。@Configuration
@RequiredArgsConstructor
public class MySecurityConfig {
/**
* 接口文档放行
*/
public static final List<String> DOC_WHITE_LIST = List.of("/doc.html", "/webjars/**", "/v3/api-docs/**");
/**
* 测试接口放行
*/
public static final List<String> TEST_WHITE_LIST = List.of("/test/**");
/**
* 验证码放行
*/
public static final List<String> VERIFY_CODE_WHITE_LIST = List.of("/sys/verifyCode/**");
/**
* 获取AuthenticationManager(认证管理器),登录时认证使用
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
/**
* 允许抛出用户不存在的异常
* @param myUserDetailsService myUserDetailsService
* @return DaoAuthenticationProvider
*/
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider(MyUserDetailsServiceImpl myUserDetailsService) {
final DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(myUserDetailsService);
provider.setUserDetailsPasswordService(myUserDetailsService);
provider.setHideUserNotFoundExceptions(false);
return provider;
}
/**
* 自定义RememberMe服务token持久化仓库
*/
@Bean
public PersistentTokenRepository persistentTokenRepository(DataSource datasource) {
final JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
//设置数据源
tokenRepository.setDataSource(datasource);
//第一次启动的时候建表
// tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
MyLoginFilter loginFilter,
MyAuthenticationHandler authenticationHandler,
MyRememberMeServices rememberMeServices
) throws Exception {
//路径配置
http.authorizeHttpRequests()
.requestMatchers(HttpMethod.GET, DOC_WHITE_LIST.toArray(new String[0])).permitAll()
.requestMatchers(HttpMethod.GET, VERIFY_CODE_WHITE_LIST.toArray(new String[0])).permitAll()
// .requestMatchers(HttpMethod.GET, TEST_WHITE_LIST.toArray(new String[0])).permitAll()
.anyRequest().authenticated()
;
//登陆
http.addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter.class);
//配置自定义登陆流程后需要关闭 否则可以使用原有登陆方式
//登出
http.logout().logoutUrl("/sys/user/logout").logoutSuccessHandler(authenticationHandler);
//禁用 csrf
// http.csrf().disable();
//csrf验证 存储到Cookie中
http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
;
//会话管理
http.sessionManagement()
.maximumSessions(1)
.expiredSessionStrategy(authenticationHandler)
//引入redis-session依赖后已不再需要手动配置 sessionRegistry
// .sessionRegistry(new SpringSessionBackedSessionRegistry<>(new RedisIndexedSessionRepository(RedisConfig.createRedisTemplate())))
//禁止后登陆挤下线
// .maxSessionsPreventsLogin(true)
;
//rememberMe
http.rememberMe().rememberMeServices(rememberMeServices);
// 权限不足时的处理
http.exceptionHandling()
.accessDeniedHandler(authenticationHandler)
.authenticationEntryPoint(authenticationHandler)
;
return http.build();
}
}
完成