Tabla de contenido
premisa
Se recomienda encarecidamente leer este artículo después de aprender el proceso de configuración de la versión 2.x
Recomendar uno: video tutorial
Funciones a implementar
- Use nombre de usuario + contraseña + código de verificación + función recordarme para iniciar sesión
- Verificación CSRF
- Entregue la sesión a Redis para su administración y mantenga la función de recordarme en la base de datos.
Dependencia (POM)
Se omite la parte de la operación de la base de datos.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.0</version>
<relativePath/>
</parent>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--session-redis-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<!--验证码-->
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
<!--springdoc -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.0.0</version>
</dependency>
<!--knife4j - 接口文档UI-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-springdoc-ui</artifactId>
<!--在引用时请在maven中央仓库搜索3.X最新版本号-->
<version>3.0.3</version>
</dependency>
</dependencies>
Nota: La documentación de la interfaz generada por springdoc+knife4j se incluye al final, y las anotaciones proporcionadas por springdoc también se incluyen en el código de muestra.
Código de muestra
Componentes básicos
código de verificación
Configuración de compilación (igual que en el video tutorial)
@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;
}
}
interfaz
Genere un código de verificación, guárdelo Session
en Attribute
y sáquelo de aquí para su posterior verificación. Las dos interfaces devuelven los datos del código de verificación en diferentes formatos.
@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);
}
}
MyUserDetailsServiceImpl (información de autenticación/permiso)
- No hay nada especial aquí, consulte y devuelva la información de autenticación del usuario de acuerdo con el nombre de usuario y
SystemUserService
proporcione una interfaz de acceso a la base de datos. - Desde que lo implementamos
UserDetailsPasswordService
,SpringSecurity
si se descubre que el método de encriptación de la contraseña del usuario está desactualizado o es texto sin formato, la contraseña se cambiará automáticamente. createUser
El método es llamarSpringSecurity
al constructor proporcionadoUser.UserBuilder
para crear unUserDetails
- Debido a que la parte de autenticación aún no se ha involucrado, aquí se proporciona directamente una lista vacía a la autoridad, y se informará de un error si no se escribe aquí.
@Service
Regístrese directamente en el contenedor
@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();
}
}
MyAuthenticationHandler(Manejador)
Debido a que solo hay un método en estas interfaces, y todas necesitan un procesamiento similar, lo armé para implementarlo y el proceso es casi el mismo:
- establecer
Content-Type
enapplication/json;charset=UTF-8
- Establecer el código de estado según la situación
- Escriba el resultado devuelto en
response
Lo único a lo que debe prestar atención es que después de un inicio de sesión exitoso, debe borrar el código de verificación utilizado
Nota: Necesitamos usar este objeto en dos lugares, así que regístrelo directamente en el contenedor para facilitar la inyección.
@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 <code>AuthenticationException</code>
* @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 <code>AccessDeniedException</code>
* @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), "注销成功")));
}
}
MyRememberMeServices (recuérdame)
La función recordarme, especifica:
- Obtener campos de
request
_Attribute
rememberMe
- Cuando el valor del campo es
TRUE_VALUES
miembro de la tabla, se considera necesario habilitar la función recordarme
en el constructor
PersistentTokenRepository
se proporcionará más adelanteUserDetailsService
proporcionado anteriormente
Nota: Necesitamos usar este objeto en dos lugares, así que regístrelo directamente en el contenedor para facilitar la inyección.
@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);
}
}
componentes básicos
MyLoginFilter (filtro de inicio de sesión)
- Los parámetros del método de construcción se pueden obtener del contenedor, por lo que aquí también se registra directamente la construcción automática del contenedor.
- Heredado
UsernamePasswordAuthenticationFilter
, lo usaremos para reemplazar el predeterminadoUsernamePasswordAuthenticationFilter
- En el constructor, especifique:
- Cómo lidiar con inicios de sesión exitosos y fallidos
- Recuérdame componente
- La ruta utilizada para iniciar sesión
attemptAuthentication
El método especifica el proceso de inicio de sesión:- Si
Content-Type
es Json, obtenga los parámetros de solicitudBody
de él ; de lo contrarioForm表单
, obténgalo de - Obtenga el código de verificación previamente guardado y compárelo con el código de verificación proporcionado por el
Session
usuarioAttribute
rememberMe
Introducir los camposrequest
facilitados por el usuarioAttribute
para su posteriorMyRememberMeServices
adquisición- La parte final proviene de la clase principal y se copia.
- Si
@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);
}
}
MySecurityConfig (configuración central)
@Bean authenticationManager
proporcionarMyLoginFilter
lo necesarioAuthenticationManager
@Bean daoAuthenticationProvider
ProporcionaMyRememberMeServices
lo que se necesitaPersistentTokenRepository
, dondesetCreateTableOnStartup
el método necesita desatar el comentario cuando se ejecuta por primera vez para permitirle construir automáticamente la tabla.@Bean securityFilterChain
El núcleo del núcleo, laHttpSecurity http
configuración correcta en la versión 2.x necesita moverse aquí, aquí configuramos:- Configuración de ruta, aquí se libera la ruta del documento de interfaz y el código de verificación, y otras solicitudes deben autenticarse. Las solicitudes de inicio de sesión no se ven afectadas por él y no requieren una configuración especial.
MyLoginFilter
Reemplace el predeterminado con uno personalizado , tenga cuidado de no escribirUsernamePasswordAuthenticationFilter
el original , de lo contrario podrá iniciar sesión sin pasar por el código de verificación.http.formLogin()
/login
- Configuración de cierre de sesión, ruta especificada y método de procesamiento de cierre de sesión correcto
- verificación csrf, tenga en cuenta que aquí se requiere una oración más que en la versión 2.x
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
- Gestión de sesiones, configurada para permitir que solo un terminal inicie sesión, no es necesario configurar
sessionRegistry
, se inyectará automáticamente, por supuesto, la configuración manual también es posible, pero no se creará automáticamente en el contenedor, debe pasar uno manualmente ,new SpringSessionBackedSessionRegistry<>(new RedisIndexedSessionRepository(redisTemplate))
queredisTemplate
necesita serRedisTemplate<String,Object>
- Recuerde mi función, tenga en cuenta que
MyLoginFilter
las dos configuraciones aquí y aquí son indispensables. - Manejo de permisos insuficientes
@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();
}
}
Finalizar