1
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package com.example.springboot4;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableDiscoveryClient // 启用 Kubernetes 服务发现
|
||||
public class Springboot4Application {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Springboot4Application.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.springboot4.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class FaviconConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/favicon.ico")
|
||||
.addResourceLocations("classpath:/static/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2020-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.example.springboot4.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class RedisConfig {
|
||||
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
|
||||
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
|
||||
redisTemplate.setConnectionFactory(redisConnectionFactory);
|
||||
// 设置key的序列化方式为String
|
||||
redisTemplate.setKeySerializer(new StringRedisSerializer());
|
||||
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
|
||||
// 设置value的序列化方式为JSON
|
||||
return redisTemplate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package com.example.springboot4.config;
|
||||
|
||||
import com.example.springboot4.filter.CaptchaAuthenticationFilter;
|
||||
import com.example.springboot4.service.RedisOAuth2AuthorizationConsentService;
|
||||
import com.example.springboot4.service.RedisOAuth2AuthorizationService;
|
||||
import com.example.springboot4.service.SmsAuthenticationProvider;
|
||||
import com.example.springboot4.service.SmsUserDetailsService;
|
||||
import com.example.springboot4.util.RsaKeyLoader;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
|
||||
import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
|
||||
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationContext;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
|
||||
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Resource
|
||||
private SmsAuthenticationProvider smsAuthenticationProvider;
|
||||
|
||||
@Resource
|
||||
private SmsUserDetailsService smsUserDetailsService;
|
||||
|
||||
private static KeyPair generateRsaKey() {
|
||||
// 尝试从本地文件加载密钥对
|
||||
try {
|
||||
java.security.PrivateKey privateKey = RsaKeyLoader.loadPrivateKey("keys/oauth2-private.key");
|
||||
java.security.PublicKey publicKey = RsaKeyLoader.loadPublicKey("keys/oauth2-public.key");
|
||||
System.out.println("成功从文件加载RSA密钥对");
|
||||
return new KeyPair(publicKey, privateKey);
|
||||
} catch (Exception ex) {
|
||||
// 如果加载失败,则生成新的密钥对
|
||||
System.err.println("警告:无法从文件加载密钥对,将生成临时密钥对: " + ex.getMessage());
|
||||
KeyPair keyPair;
|
||||
try {
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
|
||||
keyPairGenerator.initialize(2048);
|
||||
keyPair = keyPairGenerator.generateKeyPair();
|
||||
System.out.println("已生成新的临时RSA密钥对");
|
||||
} catch (Exception ex2) {
|
||||
throw new IllegalStateException(ex2);
|
||||
}
|
||||
return keyPair;
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) {
|
||||
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer();
|
||||
authorizationServerConfigurer.authorizationEndpoint(authorizationEndpoint ->
|
||||
authorizationEndpoint.consentPage("/oauth2/consent")); // 自定义授权页面
|
||||
http
|
||||
.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
|
||||
.with(authorizationServerConfigurer, (authorizationServer) ->
|
||||
authorizationServer
|
||||
.oidc(oidc -> oidc
|
||||
.userInfoEndpoint(userInfo -> userInfo
|
||||
.userInfoMapper(userInfoMapper())))
|
||||
)
|
||||
.authorizeHttpRequests((authorize) ->
|
||||
authorize.anyRequest().authenticated()
|
||||
)
|
||||
.exceptionHandling((exceptions) -> exceptions
|
||||
.defaultAuthenticationEntryPointFor(
|
||||
new LoginUrlAuthenticationEntryPoint("/login"),
|
||||
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
|
||||
)
|
||||
)
|
||||
// 配置CORS
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.csrf(csrf -> csrf.ignoringRequestMatchers(authorizationServerConfigurer.getEndpointsMatcher()));
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(2)
|
||||
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) {
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
.requestMatchers("/login", "/favicon.ico", "/css/**", "/js/**", "/images/**", "/captcha/**", "/sms/**", "/login/mobile").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.authenticationProvider(smsAuthenticationProvider)
|
||||
.userDetailsService(smsUserDetailsService)
|
||||
.addFilterBefore(new CaptchaAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) // 添加验证码过滤器,不用可以注释
|
||||
.formLogin(form -> form
|
||||
.loginPage("/login")
|
||||
.permitAll()
|
||||
)
|
||||
.sessionManagement(session -> session
|
||||
.sessionFixation().migrateSession()
|
||||
.maximumSessions(1)
|
||||
.maxSessionsPreventsLogin(false)
|
||||
)
|
||||
.logout(LogoutConfigurer::permitAll)
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))// 配置CORS
|
||||
.csrf(csrf -> csrf.ignoringRequestMatchers("/login/mobile", "/sms/**"));
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
// @Bean
|
||||
// public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
|
||||
// RegisteredClient oidcClient = RegisteredClient.withId("7e2d5d5e-0077-4853-97a8-4e49be099956")
|
||||
// .clientId("oidc-client")
|
||||
// .clientSecret("{noop}secret")
|
||||
// .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
|
||||
// .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
// .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
|
||||
// .redirectUri("http://localhost:3000/callback")
|
||||
// .redirectUri("https://c.makesong.cn/callback")
|
||||
// .redirectUri("https://baidu.com")
|
||||
// .postLogoutRedirectUri("https://c.makesong.cn/callback")
|
||||
// .scope(OidcScopes.OPENID)
|
||||
// .scope(OidcScopes.PROFILE)
|
||||
// .scope(OidcScopes.EMAIL)
|
||||
// .scope(OidcScopes.ADDRESS)
|
||||
// .scope(OidcScopes.PHONE)
|
||||
// .tokenSettings(TokenSettings.builder()
|
||||
// .accessTokenTimeToLive(Duration.ofSeconds(300))
|
||||
// .refreshTokenTimeToLive(Duration.ofDays(30))
|
||||
// .build())
|
||||
// .clientSettings(ClientSettings.builder()
|
||||
// .requireAuthorizationConsent(true) // 启用授权同意页面
|
||||
// .requireProofKey(true) // 禁用PKCE要求,如需要可以开启
|
||||
// .build())
|
||||
// .build();
|
||||
// JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
|
||||
//
|
||||
// registeredClientRepository.save(oidcClient); //添加一条client,也可以在数据库中手动添加
|
||||
// return registeredClientRepository;
|
||||
// }
|
||||
|
||||
@Bean
|
||||
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
|
||||
return new JdbcRegisteredClientRepository(jdbcTemplate);
|
||||
}
|
||||
|
||||
// CORS配置源
|
||||
@Bean
|
||||
CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOriginPatterns(List.of("*"));
|
||||
configuration.setAllowedMethods(List.of("*"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
//使用JDBC存储Client信息,支持动态存储
|
||||
// @Bean
|
||||
// public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
|
||||
// return new JdbcRegisteredClientRepository(jdbcTemplate);
|
||||
// }
|
||||
|
||||
// 不再需要 @Bean 注解,因为 smsUserDetailsService 已经是 UserDetailsService bean
|
||||
public UserDetailsService userDetailsService() {
|
||||
return smsUserDetailsService;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
|
||||
return authenticationConfiguration.getAuthenticationManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OAuth2AuthorizationConsentService authorizationConsentService(
|
||||
RedisTemplate<String, Object> redisTemplate, RegisteredClientRepository registeredClientRepository) {
|
||||
return new RedisOAuth2AuthorizationConsentService(redisTemplate, registeredClientRepository);
|
||||
}
|
||||
|
||||
|
||||
// 使用Redis存储授权后的信息
|
||||
@Bean
|
||||
public OAuth2AuthorizationService authorizationService(
|
||||
RedisTemplate<String, Object> redisTemplate,
|
||||
RegisteredClientRepository registeredClientRepository) {
|
||||
return new RedisOAuth2AuthorizationService(redisTemplate, registeredClientRepository);
|
||||
}
|
||||
|
||||
//使用默认地址映射
|
||||
@Bean
|
||||
public AuthorizationServerSettings authorizationServerSettings() {
|
||||
return AuthorizationServerSettings.builder().build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
// 自定义ID_TOKEN信息
|
||||
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(
|
||||
UserDetailsService userInfoService) {
|
||||
return (context) -> {
|
||||
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
|
||||
UserDetails userDetails = userInfoService.loadUserByUsername(context.getPrincipal().getName());
|
||||
context.getClaims().claims(claims -> {
|
||||
// claims.clear();
|
||||
if (userDetails instanceof Users customUser) {
|
||||
claims.put("nickname", customUser.getNickname());
|
||||
claims.put("avatar", customUser.getAvatar());
|
||||
claims.put("phone", customUser.getPhone());
|
||||
claims.put("email", customUser.getEmail());
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 自定义用户信息映射
|
||||
@Bean
|
||||
public Function<OidcUserInfoAuthenticationContext, OidcUserInfo> userInfoMapper() {
|
||||
return (context -> {
|
||||
// 获取认证信息
|
||||
Authentication authentication = context.getAuthentication();
|
||||
try {
|
||||
String username = authentication.getName();
|
||||
|
||||
UserDetails userDetails = userDetailsService().loadUserByUsername(username);
|
||||
|
||||
// 如果是自定义的Users类型,返回自定义的用户信息,否则返回默认的用户信息
|
||||
if (userDetails instanceof Users customUser) {
|
||||
return new OidcUserInfo(customUser.getOidcUserInfo());
|
||||
}
|
||||
} catch (Exception _) {
|
||||
|
||||
}
|
||||
JwtAuthenticationToken principal = (JwtAuthenticationToken) authentication.getPrincipal();
|
||||
return new OidcUserInfo(principal.getToken().getClaims());
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JWKSource<SecurityContext> jwkSource() {
|
||||
KeyPair keyPair = generateRsaKey();
|
||||
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
|
||||
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
|
||||
RSAKey rsaKey = new RSAKey.Builder(publicKey)
|
||||
.privateKey(privateKey)
|
||||
.keyID("oauth2-jwk-key")
|
||||
.build();
|
||||
JWKSet jwkSet = new JWKSet(rsaKey);
|
||||
return new ImmutableJWKSet<>(jwkSet);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
|
||||
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
|
||||
}
|
||||
}
|
||||
55
auth/src/main/java/com/example/springboot4/config/Users.java
Normal file
55
auth/src/main/java/com/example/springboot4/config/Users.java
Normal file
@@ -0,0 +1,55 @@
|
||||
package com.example.springboot4.config;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
// 自定义用户信息
|
||||
public class Users extends User {
|
||||
public String nickname;
|
||||
private String email;
|
||||
private String phone;
|
||||
private String avatar;
|
||||
|
||||
public Users(String username, @Nullable String password, Collection<? extends GrantedAuthority> authorities, String nickname, String avatar, String phone) {
|
||||
super(username, password, authorities);
|
||||
this.nickname = nickname;
|
||||
this.avatar = avatar;
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public Map<String, Object> getOidcUserInfo() {
|
||||
return OidcUserInfo.builder()
|
||||
.subject(getUsername())
|
||||
.name("First Last")
|
||||
.givenName("First")
|
||||
.familyName("Last")
|
||||
.middleName("Middle")
|
||||
.nickname(nickname)
|
||||
.preferredUsername(getUsername())
|
||||
.profile("https://example.com/" + nickname)
|
||||
.picture(avatar)
|
||||
.website("https://example.com")
|
||||
.email(nickname + "@example.com")
|
||||
.emailVerified(true)
|
||||
.gender("female")
|
||||
.birthdate("1970-01-01")
|
||||
.zoneinfo("Europe/Paris")
|
||||
.locale("en-US")
|
||||
.phoneNumber(phone)
|
||||
.phoneNumberVerified(false)
|
||||
.claim("address", Collections.singletonMap("formatted", "Champ de Mars\n5 Av. Anatole France\n75007 Paris\nFrance"))
|
||||
.updatedAt("1970-01-01T00:00:00Z")
|
||||
.build()
|
||||
.getClaims();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.example.springboot4.controller;
|
||||
|
||||
import com.example.springboot4.util.CaptchaUtil;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/captcha")
|
||||
public class CaptchaController {
|
||||
/**
|
||||
* 生成验证码
|
||||
*/
|
||||
@GetMapping("/generate")
|
||||
public Map<String, String> generateCaptcha(HttpSession session) {
|
||||
|
||||
CaptchaUtil.Captcha captcha = CaptchaUtil.generateCaptcha();
|
||||
// 将验证码文本存储在session中,也可以自定义Redis存储验证码
|
||||
session.setAttribute("captcha", captcha.getText());
|
||||
|
||||
// 返回验证码图片Base64编码
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("image", "data:image/png;base64," + captcha.getImageBase64());
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.example.springboot4.controller;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Controller
|
||||
public class LoginController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LoginController.class);
|
||||
|
||||
@GetMapping("/")
|
||||
public String home() {
|
||||
return "home";
|
||||
}
|
||||
|
||||
@GetMapping("/login")
|
||||
public String login(@RequestParam(defaultValue = "false") boolean error,
|
||||
@RequestParam(defaultValue = "false") boolean logout,
|
||||
@RequestParam(defaultValue = "false") boolean captcha) {
|
||||
return "login";
|
||||
}
|
||||
|
||||
@GetMapping("/oauth2/consent")
|
||||
public String consent(HttpServletRequest request, Model model) {
|
||||
// 虽然不能直接拿到 code_challenge,但你可以相信框架已保存
|
||||
// 只需确保表单正确提交即可
|
||||
String clientId = request.getParameter("client_id");
|
||||
String scopeStr = request.getParameter("scope");
|
||||
String state = request.getParameter("state");
|
||||
|
||||
// 参数验证
|
||||
if (clientId == null || clientId.trim().isEmpty()) {
|
||||
logger.warn("consent页面请求缺少client_id参数");
|
||||
return "error"; // 或重定向到错误页面
|
||||
}
|
||||
|
||||
// 处理scope参数
|
||||
List<String> scopes = Collections.emptyList();
|
||||
if (scopeStr != null && !scopeStr.trim().isEmpty()) {
|
||||
scopes = Arrays.asList(scopeStr.trim().split("\\s+"));
|
||||
}
|
||||
model.addAttribute("clientId", request.getParameter("client_id"));
|
||||
model.addAttribute("scopes", scopes);
|
||||
model.addAttribute("state", request.getParameter("state"));
|
||||
|
||||
// 注意:不要试图在这里构造 redirect_uri 或 code_challenge!
|
||||
return "consent";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.example.springboot4.controller;
|
||||
|
||||
import com.example.springboot4.service.SmsAuthenticationToken;
|
||||
import com.example.springboot4.service.SmsCodeService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
|
||||
import org.springframework.security.web.savedrequest.SavedRequest;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Controller
|
||||
public class SmsController {
|
||||
|
||||
private final SmsCodeService smsCodeService;
|
||||
private final AuthenticationManager authenticationManager;
|
||||
|
||||
@Autowired
|
||||
public SmsController(SmsCodeService smsCodeService, AuthenticationManager authenticationManager) {
|
||||
this.smsCodeService = smsCodeService;
|
||||
this.authenticationManager = authenticationManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
*/
|
||||
@PostMapping("/sms/code")
|
||||
@ResponseBody
|
||||
public String sendCode(@RequestParam("phone") String phone, RedirectAttributes redirectAttributes) {
|
||||
// 生成验证码
|
||||
String code = smsCodeService.generateCode();
|
||||
// 发送验证码(模拟)
|
||||
smsCodeService.sendCode(phone, code);
|
||||
// 添加提示信息
|
||||
redirectAttributes.addFlashAttribute("message", "验证码已发送至 " + phone);
|
||||
return "成功";
|
||||
}
|
||||
|
||||
/**
|
||||
* 短信验证码登录
|
||||
*/
|
||||
@PostMapping("/login/mobile")
|
||||
public String loginWithSms(@RequestParam("phone") String phone,
|
||||
@RequestParam("code") String code,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
// 创建未认证的Token
|
||||
SmsAuthenticationToken authRequest = new SmsAuthenticationToken(phone, code);
|
||||
try {
|
||||
// 调用AuthenticationManager进行认证
|
||||
Authentication authentication = authenticationManager.authenticate(authRequest);
|
||||
// 设置认证信息到SecurityContext
|
||||
SecurityContext context = SecurityContextHolder.getContext();
|
||||
context.setAuthentication(authentication);
|
||||
|
||||
// 保存SecurityContext到HttpSession,确保认证状态持久化
|
||||
HttpSession session = request.getSession(true);
|
||||
session.setAttribute("SPRING_SECURITY_CONTEXT", context);
|
||||
|
||||
// 获取保存的请求(如授权URL),如果存在则重定向到保存的请求
|
||||
HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
|
||||
SavedRequest savedRequest = requestCache.getRequest(request, response);
|
||||
if (savedRequest != null) {
|
||||
String targetUrl = savedRequest.getRedirectUrl();
|
||||
// 清除保存的请求,避免重复使用
|
||||
requestCache.removeRequest(request, response);
|
||||
// 确保重定向URL是安全的(只允许重定向到授权端点)
|
||||
if (targetUrl.contains("/oauth2/authorize")) {
|
||||
return "redirect:" + targetUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有保存的请求或URL不安全,重定向到首页
|
||||
return "redirect:/";
|
||||
} catch (Exception e) {
|
||||
// 认证失败
|
||||
redirectAttributes.addFlashAttribute("error", "短信验证码错误或已过期");
|
||||
return "redirect:/login";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.example.springboot4.filter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 验证码验证过滤器
|
||||
* 在用户认证之前验证验证码是否正确
|
||||
*/
|
||||
public class CaptchaAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CaptchaAuthenticationFilter.class);
|
||||
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
|
||||
// 只处理登录POST请求
|
||||
if (!shouldProcess(request)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 获取用户输入的验证码
|
||||
String inputCaptcha = request.getParameter("captcha");
|
||||
|
||||
// 获取Session中的验证码
|
||||
HttpSession session = request.getSession(false);
|
||||
String sessionCaptcha = (session != null) ? (String) session.getAttribute("captcha") : null;
|
||||
|
||||
// 移除Session中的验证码,确保一次有效性
|
||||
if (session != null) {
|
||||
session.removeAttribute("captcha");
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
if (inputCaptcha == null || !inputCaptcha.equalsIgnoreCase(sessionCaptcha)) {
|
||||
// logger.warn("验证码验证失败. 用户输入: {}, Session中: {}", inputCaptcha, sessionCaptcha);
|
||||
// 验证码错误,重定向到登录页并携带错误参数
|
||||
response.sendRedirect("/login?captcha");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("验证码验证通过,继续执行认证流程");
|
||||
// 验证码正确,继续执行过滤器链
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private boolean shouldProcess(HttpServletRequest request) {
|
||||
return "/login".equals(request.getRequestURI()) &&
|
||||
"POST".equalsIgnoreCase(request.getMethod());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.example.springboot4.filter;
|
||||
|
||||
import com.example.springboot4.handler.ErrorResponse;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 自定义认证入口点,用于处理认证异常并返回统一格式的JSON响应
|
||||
*/
|
||||
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class);
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
public CustomAuthenticationEntryPoint() {
|
||||
// 注册JavaTimeModule以支持LocalDateTime序列化
|
||||
objectMapper.registerModule(new JavaTimeModule());
|
||||
// 禁用时间戳格式,使用字符串格式
|
||||
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commence(HttpServletRequest request, HttpServletResponse response,
|
||||
AuthenticationException authException) throws IOException, ServletException {
|
||||
|
||||
logger.warn("认证失败: {}", authException.getMessage());
|
||||
|
||||
ErrorResponse errorResponse = new ErrorResponse();
|
||||
errorResponse.setTimestamp(LocalDateTime.now());
|
||||
|
||||
if (authException instanceof InvalidBearerTokenException) {
|
||||
errorResponse.setMessage("Token无效或已过期: " + authException.getMessage());
|
||||
errorResponse.setCode(HttpStatus.UNAUTHORIZED.value());
|
||||
} else {
|
||||
// errorResponse.setMessage("认证失败: " + authException.getMessage());
|
||||
// errorResponse.setCode(HttpStatus.UNAUTHORIZED.value());
|
||||
response.sendRedirect("/login");
|
||||
}
|
||||
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
|
||||
String jsonResponse = objectMapper.writeValueAsString(errorResponse);
|
||||
response.getWriter().write(jsonResponse);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.example.springboot4.handler;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 统一错误响应格式
|
||||
*/
|
||||
@Setter
|
||||
@Getter
|
||||
public class ErrorResponse {
|
||||
// Getter 和 Setter 方法
|
||||
private boolean success = false;
|
||||
private String message;
|
||||
private int code;
|
||||
private LocalDateTime timestamp = LocalDateTime.now();
|
||||
private Map<String, Object> data;
|
||||
|
||||
// 构造函数
|
||||
public ErrorResponse() {}
|
||||
|
||||
public ErrorResponse(String message, int code) {
|
||||
this.message = message;
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public ErrorResponse(String message, int code, Map<String, Object> data) {
|
||||
this.message = message;
|
||||
this.code = code;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.example.springboot4.service;
|
||||
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
//自定义Redis 授权同意服务
|
||||
public class RedisOAuth2AuthorizationConsentService implements OAuth2AuthorizationConsentService {
|
||||
|
||||
private static final String CONSENT_KEY_PREFIX = "consent:";
|
||||
// 可选:设置授权时间,授权时间内不需要手动点击授权按钮(例如 30 天)
|
||||
private static final long CONSENT_EXPIRE_DAYS = 60 * 5;
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final RegisteredClientRepository registeredClientRepository; // ← 新增
|
||||
|
||||
public RedisOAuth2AuthorizationConsentService(RedisTemplate<String, Object> redisTemplate, RegisteredClientRepository registeredClientRepository) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.registeredClientRepository = registeredClientRepository;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(OAuth2AuthorizationConsent authorizationConsent) {
|
||||
if (authorizationConsent == null) {
|
||||
return;
|
||||
}
|
||||
String key = getConsentKey(authorizationConsent.getRegisteredClientId(), authorizationConsent.getPrincipalName());
|
||||
redisTemplate.opsForValue().set(key, authorizationConsent, CONSENT_EXPIRE_DAYS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(OAuth2AuthorizationConsent authorizationConsent) {
|
||||
if (authorizationConsent == null) {
|
||||
return;
|
||||
}
|
||||
String key = getConsentKey(authorizationConsent.getRegisteredClientId(), authorizationConsent.getPrincipalName());
|
||||
redisTemplate.delete(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AuthorizationConsent findById(String registeredClientId, String principalName) {
|
||||
RegisteredClient registeredClient = registeredClientRepository.findById(registeredClientId);
|
||||
if (registeredClient == null) {
|
||||
// 客户端不存在,即使 Redis 里有 consent 也视为无效
|
||||
return null;
|
||||
}
|
||||
String key = getConsentKey(registeredClientId, principalName);
|
||||
return (OAuth2AuthorizationConsent) redisTemplate.opsForValue().get(key);
|
||||
}
|
||||
|
||||
private static String getConsentKey(String registeredClientId, String principalName) {
|
||||
return CONSENT_KEY_PREFIX + principalName + ":" + registeredClientId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.example.springboot4.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
// RedisOAuth2AuthorizationService.java
|
||||
// Redis 自定义存储授权信息
|
||||
public class RedisOAuth2AuthorizationService implements OAuth2AuthorizationService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(RedisOAuth2AuthorizationService.class);
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final RegisteredClientRepository registeredClientRepository;
|
||||
|
||||
public RedisOAuth2AuthorizationService(RedisTemplate<String, Object> redisTemplate,
|
||||
RegisteredClientRepository registeredClientRepository) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.registeredClientRepository = registeredClientRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(OAuth2Authorization authorization) {
|
||||
try {
|
||||
String key = "oauth2:authorization:" + authorization.getId();
|
||||
redisTemplate.opsForValue().set(key, authorization, Duration.ofDays(30));
|
||||
// 同时按token值存储索引
|
||||
if (authorization.getAccessToken() != null) {
|
||||
String tokenKey = "oauth2:token:" + authorization.getAccessToken().getToken().getTokenValue();
|
||||
redisTemplate.opsForValue().set(tokenKey, authorization.getId(), Duration.ofSeconds(300));
|
||||
}
|
||||
|
||||
if (authorization.getRefreshToken() != null) {
|
||||
String refreshTokenKey = "oauth2:refresh:" + authorization.getRefreshToken().getToken().getTokenValue();
|
||||
redisTemplate.opsForValue().set(refreshTokenKey, authorization.getId(), Duration.ofDays(14));
|
||||
}
|
||||
|
||||
// 添加对授权码的支持
|
||||
if (authorization.getAuthorizationGrantType().getValue().equals(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())) {
|
||||
OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode =
|
||||
authorization.getToken(OAuth2AuthorizationCode.class);
|
||||
if (authorizationCode != null) {
|
||||
String codeKey = "oauth2:code:" + authorizationCode.getToken().getTokenValue();
|
||||
redisTemplate.opsForValue().set(codeKey, authorization.getId(), Duration.ofMinutes(5));
|
||||
} else {
|
||||
//第一次授权,需要用户手动点击授权按钮,根据state存储,用于后续验证
|
||||
String stateKey = "oauth2:state:" + authorization.getAttribute("state");
|
||||
redisTemplate.opsForValue().set(stateKey, authorization.getId(), Duration.ofSeconds(300));
|
||||
}
|
||||
}
|
||||
} catch (DataAccessException e) {
|
||||
logger.error("保存OAuth2授权信息到Redis时发生错误", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(OAuth2Authorization authorization) {
|
||||
try {
|
||||
String key = "oauth2:authorization:" + authorization.getId();
|
||||
redisTemplate.delete(key);
|
||||
if (authorization.getAccessToken() != null) {
|
||||
String tokenKey = "oauth2:token:" + authorization.getAccessToken().getToken().getTokenValue();
|
||||
redisTemplate.delete(tokenKey);
|
||||
}
|
||||
if (authorization.getRefreshToken() != null) {
|
||||
String refreshTokenKey = "oauth2:refresh:" + authorization.getRefreshToken().getToken().getTokenValue();
|
||||
redisTemplate.delete(refreshTokenKey);
|
||||
}
|
||||
} catch (DataAccessException e) {
|
||||
logger.error("从Redis删除OAuth2授权信息时发生错误", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2Authorization findById(String id) {
|
||||
try {
|
||||
String key = "oauth2:authorization:" + id;
|
||||
return (OAuth2Authorization) redisTemplate.opsForValue().get(key);
|
||||
} catch (DataAccessException e) {
|
||||
logger.error("从Redis查询OAuth2授权信息时发生错误", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) {
|
||||
try {
|
||||
String authId = null;
|
||||
|
||||
if (tokenType != null) {
|
||||
// 1. 如果类型明确,直接获取 Key
|
||||
authId = (String) redisTemplate.opsForValue().get(getTokenKey(token, tokenType));
|
||||
} else {
|
||||
// 2. 如果类型为空 (revoke 流程),遍历所有可能的 Redis 前缀
|
||||
// 这里的顺序建议:Refresh Token -> Access Token -> Code
|
||||
String[] possibleKeys = {
|
||||
"oauth2:refresh:" + token,
|
||||
"oauth2:token:" + token,
|
||||
"oauth2:code:" + token
|
||||
};
|
||||
|
||||
for (String key : possibleKeys) {
|
||||
authId = (String) redisTemplate.opsForValue().get(key);
|
||||
if (authId != null) break;
|
||||
}
|
||||
}
|
||||
|
||||
return authId != null ? findById(authId) : null;
|
||||
} catch (DataAccessException e) {
|
||||
logger.error("根据Token从Redis查询OAuth2授权信息时发生错误", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private String getTokenKey(String token, OAuth2TokenType tokenType) {
|
||||
// 增加空指针保护
|
||||
if (tokenType == null) {
|
||||
return "oauth2:token:" + token;
|
||||
}
|
||||
|
||||
if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) {
|
||||
return "oauth2:token:" + token;
|
||||
} else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) {
|
||||
return "oauth2:refresh:" + token;
|
||||
} else if ("code".equals(tokenType.getValue())) {
|
||||
return "oauth2:code:" + token;
|
||||
} else if ("state".equals(tokenType.getValue())) {
|
||||
return "oauth2:state:" + token;
|
||||
}
|
||||
return "oauth2:token:" + token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.example.springboot4.service;
|
||||
|
||||
import org.jspecify.annotations.NonNull;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class SmsAuthenticationProvider implements AuthenticationProvider {
|
||||
|
||||
private final SmsCodeService smsCodeService;
|
||||
private final SmsUserDetailsService userDetailsService;
|
||||
|
||||
public SmsAuthenticationProvider(SmsCodeService smsCodeService, SmsUserDetailsService userDetailsService) {
|
||||
this.smsCodeService = smsCodeService;
|
||||
this.userDetailsService = userDetailsService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Authentication authenticate(@NonNull Authentication authentication) throws AuthenticationException {
|
||||
SmsAuthenticationToken auth = (SmsAuthenticationToken) authentication;
|
||||
String phone = (String) auth.getPrincipal();
|
||||
String code = (String) auth.getCredentials();
|
||||
|
||||
// 验证短信验证码
|
||||
if (!smsCodeService.verifyCode(phone, code)) {
|
||||
throw new BadCredentialsException("短信验证码错误或已过期");
|
||||
}
|
||||
|
||||
// 加载用户
|
||||
UserDetails userDetails = userDetailsService.loadUserByPhone(phone);
|
||||
|
||||
// 创建已认证的Token
|
||||
SmsAuthenticationToken authenticatedToken = new SmsAuthenticationToken(
|
||||
phone, userDetails.getAuthorities());
|
||||
authenticatedToken.setDetails(userDetails);
|
||||
return authenticatedToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(@NonNull Class<?> authentication) {
|
||||
return SmsAuthenticationToken.class.isAssignableFrom(authentication);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.example.springboot4.service;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
private final Object principal; // 手机号
|
||||
private final Object credentials; // 验证码
|
||||
|
||||
/**
|
||||
* 用于认证前的构造函数(未认证)
|
||||
*/
|
||||
public SmsAuthenticationToken(String phone, String code) {
|
||||
super((Collection<? extends GrantedAuthority>) null);
|
||||
this.principal = phone;
|
||||
this.credentials = code;
|
||||
setAuthenticated(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于认证后的构造函数(已认证)
|
||||
*/
|
||||
public SmsAuthenticationToken(String phone, Collection<? extends GrantedAuthority> authorities) {
|
||||
super(authorities);
|
||||
this.principal = phone;
|
||||
this.credentials = null;
|
||||
super.setAuthenticated(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return principal;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.example.springboot4.service;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
public class SmsCodeService {
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private static final String SMS_CODE_PREFIX = "sms:code:";
|
||||
private static final long EXPIRE_MINUTES = 5;
|
||||
private static final int CODE_LENGTH = 6;
|
||||
|
||||
public SmsCodeService(StringRedisTemplate redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机6位数字验证码
|
||||
*/
|
||||
public String generateCode() {
|
||||
Random random = new Random();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < CODE_LENGTH; i++) {
|
||||
sb.append(random.nextInt(10));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存验证码到Redis,5分钟有效
|
||||
*/
|
||||
public void saveCode(String phone, String code) {
|
||||
String key = SMS_CODE_PREFIX + phone;
|
||||
redisTemplate.opsForValue().set(key, code, EXPIRE_MINUTES, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号和验证码
|
||||
*/
|
||||
public boolean verifyCode(String phone, String code) {
|
||||
String key = SMS_CODE_PREFIX + phone;
|
||||
String storedCode = redisTemplate.opsForValue().get(key);
|
||||
if (storedCode == null) {
|
||||
return false;
|
||||
}
|
||||
// 验证通过后删除验证码,防止重复使用
|
||||
if (storedCode.equals(code)) {
|
||||
redisTemplate.delete(key);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码(模拟)
|
||||
*/
|
||||
public void sendCode(String phone, String code) {
|
||||
// 这里模拟发送短信,实际应调用短信服务商API
|
||||
System.out.println("发送短信验证码到 " + phone + ": " + code);
|
||||
// 保存到Redis
|
||||
saveCode(phone, code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.example.springboot4.service;
|
||||
|
||||
import com.example.springboot4.config.Users;
|
||||
import org.jspecify.annotations.NonNull;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.provisioning.JdbcUserDetailsManager;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.Collections;
|
||||
|
||||
@Service
|
||||
@Primary
|
||||
public class SmsUserDetailsService implements UserDetailsService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final JdbcUserDetailsManager jdbcUserDetailsManager;
|
||||
private final PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
||||
|
||||
public SmsUserDetailsService(JdbcTemplate jdbcTemplate, DataSource dataSource) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.jdbcUserDetailsManager = new JdbcUserDetailsManager(dataSource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable UserDetails loadUserByUsername(@NonNull String username) throws UsernameNotFoundException {
|
||||
|
||||
// 模拟数据库
|
||||
if ("admin".equals(username)) {
|
||||
return new Users("admin", passwordEncoder.encode("123123"),
|
||||
Users.withUsername("admin").password(passwordEncoder.encode("123123")).roles("USER").build().getAuthorities(),
|
||||
"凌萧", "https://img.makesong.cn/10.png", "13777777777");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* 通过手机号加载用户
|
||||
*/
|
||||
public UserDetails loadUserByPhone(String phone) throws UsernameNotFoundException {
|
||||
// 查询users表,假设有phone列
|
||||
String sql = "SELECT username, password, enabled FROM users WHERE phone = ?";
|
||||
try {
|
||||
return jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
|
||||
String username = rs.getString("username");
|
||||
String password = rs.getString("password");
|
||||
boolean enabled = rs.getBoolean("enabled");
|
||||
// 暂时忽略权限,使用默认权限
|
||||
return new Users(username, password, Users.withUsername(username).roles("USER").build().getAuthorities(), "", "", phone);
|
||||
}, phone);
|
||||
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
|
||||
// 用户不存在,自动创建用户
|
||||
return createUserByPhone(phone);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据手机号创建新用户
|
||||
*/
|
||||
private UserDetails createUserByPhone(String phone) {
|
||||
// 生成用户名:手机号
|
||||
String username = phone;
|
||||
// 生成随机密码(用户无法用密码登录,只能短信登录)
|
||||
String password = passwordEncoder.encode(generateRandomPassword());
|
||||
// 使用JdbcUserDetailsManager创建用户
|
||||
jdbcUserDetailsManager.createUser(org.springframework.security.core.userdetails.User.builder()
|
||||
.username(username)
|
||||
.password(password)
|
||||
.roles("USER")
|
||||
.build());
|
||||
// 更新phone列(需要自定义SQL,因为JdbcUserDetailsManager不处理phone)
|
||||
String updateSql = "UPDATE users SET phone = ? WHERE username = ?";
|
||||
jdbcTemplate.update(updateSql, phone, username);
|
||||
// 返回用户详情
|
||||
return new Users(username, password, Collections.emptyList(), "", "", phone);
|
||||
}
|
||||
|
||||
private String generateRandomPassword() {
|
||||
// 生成随机字符串作为密码,用户不会用到
|
||||
return java.util.UUID.randomUUID().toString().substring(0, 16);
|
||||
}
|
||||
}
|
||||
112
auth/src/main/java/com/example/springboot4/util/CaptchaUtil.java
Normal file
112
auth/src/main/java/com/example/springboot4/util/CaptchaUtil.java
Normal file
@@ -0,0 +1,112 @@
|
||||
package com.example.springboot4.util;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.Random;
|
||||
|
||||
public class CaptchaUtil {
|
||||
|
||||
private static final char[] CHARS = {'2', '3', '4', '5', '6', '7', '8', '9',
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M',
|
||||
'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'};
|
||||
|
||||
private static final int SIZE = 4;
|
||||
private static final int LINES = 5;
|
||||
private static final int WIDTH = 120;
|
||||
private static final int HEIGHT = 40;
|
||||
|
||||
/**
|
||||
* 生成验证码图片和文本
|
||||
*/
|
||||
public static Captcha generateCaptcha() {
|
||||
BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics g = image.getGraphics();
|
||||
|
||||
// 设置背景色
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
// 生成随机验证码
|
||||
Random random = new Random();
|
||||
StringBuilder captchaText = new StringBuilder();
|
||||
for (int i = 0; i < SIZE; i++) {
|
||||
char c = CHARS[random.nextInt(CHARS.length)];
|
||||
captchaText.append(c);
|
||||
}
|
||||
|
||||
// 绘制验证码文本
|
||||
// 使用系统默认字体替代指定字体
|
||||
// g.setFont(new Font("Arial", Font.BOLD, 24));
|
||||
Font font = new Font(null, Font.BOLD, 24);
|
||||
g.setFont(font);
|
||||
|
||||
for (int i = 0; i < captchaText.length(); i++) {
|
||||
g.setColor(getRandomColor());
|
||||
g.drawString(String.valueOf(captchaText.charAt(i)), 20 + i * 20, 30);
|
||||
}
|
||||
|
||||
// 绘制干扰线
|
||||
for (int i = 0; i < LINES; i++) {
|
||||
g.setColor(getRandomColor());
|
||||
int x1 = random.nextInt(WIDTH);
|
||||
int y1 = random.nextInt(HEIGHT);
|
||||
int x2 = random.nextInt(WIDTH);
|
||||
int y2 = random.nextInt(HEIGHT);
|
||||
g.drawLine(x1, y1, x2, y2);
|
||||
}
|
||||
|
||||
g.dispose();
|
||||
|
||||
// 将图片转换为Base64编码
|
||||
String base64Image = imageToBase64(image);
|
||||
|
||||
return new Captcha(captchaText.toString(), base64Image);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取随机颜色
|
||||
*/
|
||||
private static Color getRandomColor() {
|
||||
Random random = new Random();
|
||||
return new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将BufferedImage转换为Base64编码
|
||||
*/
|
||||
private static String imageToBase64(BufferedImage image) {
|
||||
try {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "png", outputStream);
|
||||
byte[] imageBytes = outputStream.toByteArray();
|
||||
return Base64.getEncoder().encodeToString(imageBytes);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to convert image to Base64", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码数据类
|
||||
*/
|
||||
public static class Captcha {
|
||||
private final String text;
|
||||
private final String imageBase64;
|
||||
|
||||
public Captcha(String text, String imageBase64) {
|
||||
this.text = text;
|
||||
this.imageBase64 = imageBase64;
|
||||
}
|
||||
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
public String getImageBase64() {
|
||||
return imageBase64;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.example.springboot4.util;
|
||||
|
||||
|
||||
import org.springframework.util.ResourceUtils;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
|
||||
/**
|
||||
* RSA密钥加载器
|
||||
* 用于从本地文件加载RSA密钥对
|
||||
*/
|
||||
public class RsaKeyLoader {
|
||||
|
||||
/**
|
||||
* 从指定路径加载私钥
|
||||
*
|
||||
* @param privateKeyPath 私钥文件路径
|
||||
* @return PrivateKey 私钥对象
|
||||
* @throws IOException IO异常
|
||||
* @throws ClassNotFoundException 类未找到异常
|
||||
*/
|
||||
public static PrivateKey loadPrivateKey(String privateKeyPath) throws IOException, ClassNotFoundException {
|
||||
try (ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(Path.of(privateKeyPath)))) {
|
||||
return (PrivateKey) ois.readObject();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从指定路径加载公钥
|
||||
*
|
||||
* @param publicKeyPath 公钥文件路径
|
||||
* @return PublicKey 公钥对象
|
||||
* @throws IOException IO异常
|
||||
* @throws ClassNotFoundException 类未找到异常
|
||||
*/
|
||||
public static PublicKey loadPublicKey(String publicKeyPath) throws IOException, ClassNotFoundException {
|
||||
try (ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(Path.of(publicKeyPath)))) {
|
||||
return (PublicKey) ois.readObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user