diff --git a/pom.xml b/pom.xml index 1ba2949..30d2c86 100644 --- a/pom.xml +++ b/pom.xml @@ -108,6 +108,24 @@ springdoc-openapi-starter-webmvc-api 2.6.0 + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.security + spring-security-test + test + + + org.projectlombok + lombok + provided + diff --git a/src/main/java/com/example/demo/config/SecurityConfig.java b/src/main/java/com/example/demo/config/SecurityConfig.java new file mode 100644 index 0000000..64836cb --- /dev/null +++ b/src/main/java/com/example/demo/config/SecurityConfig.java @@ -0,0 +1,189 @@ +package com.example.demo.config; + +import com.example.demo.exception.CustomAuthenticationEntryPoint; +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationServiceException; +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.web.SecurityFilterChain; + +import java.util.*; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + @Getter + @Setter + private CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + + @Autowired + public SecurityConfig(CustomAuthenticationEntryPoint customAuthenticationEntryPoint) { + this.customAuthenticationEntryPoint = customAuthenticationEntryPoint; + } + + + private static final String[] WHITE_LIST = {"/swagger-ui/**", "/v3/api-docs/**", "/swagger/**"}; + +// @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") +// String issuerUri; + + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") + private String jwkSetUri; + + String jwtTokenType = "id_token"; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.exceptionHandling( + exception -> exception.authenticationEntryPoint(customAuthenticationEntryPoint)) + .authorizeHttpRequests(authorizeRequests -> + authorizeRequests.requestMatchers(WHITE_LIST).permitAll().anyRequest().authenticated() + ) + .sessionManagement(sessionManagement -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .csrf(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .oauth2ResourceServer(oauth2ResourceServerConfigurer -> + oauth2ResourceServerConfigurer.jwt(jwtConfigurer -> { + jwtConfigurer.decoder(customJwtDecoder()); + jwtConfigurer.jwtAuthenticationConverter(jwtAuthenticationConverter()); + }) + ); + ; + + return http.build(); + } + + @Bean + public JwtDecoder customJwtDecoder() { + // 使用NimbusJwtDecoder从JWKS URL读取密钥 + return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).jwtProcessorCustomizer(jwtProcessor -> { + jwtProcessor.setJWETypeVerifier((header, context) -> { + if (!header.getType().equals(jwtTokenType)) { + throw new AuthenticationServiceException("Invalid token type: " + header.getType()); + } + }); + + jwtProcessor.setJWSTypeVerifier((joseObjectType, securityContext) -> { + if (!joseObjectType.getType().equals(jwtTokenType)) { + throw new AuthenticationServiceException("Invalid token type: " + joseObjectType); + } + }); + }).build(); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + // 提取权限 + converter.setJwtGrantedAuthoritiesConverter(this::extractAuthoritiesFromJwt); + return converter; + } + + private Collection extractAuthoritiesFromJwt(Jwt jwt) { + // 从JWT的claims中提取角色和权限信息 + Map claims = jwt.getClaims(); + List authorities = new ArrayList<>(); + + // 提取角色 + if (claims.containsKey("roles") && claims.get("roles") instanceof List) { + List roles = (List) claims.get("roles"); + authorities.addAll(roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList()); + } + + // 提取权限 + if (claims.containsKey("permissions") && claims.get("permissions") instanceof List) { + List permissions = (List) claims.get("permissions"); + authorities.addAll(permissions.stream() + .map(SimpleGrantedAuthority::new) + .toList()); + } + + return authorities; + } + + +// +// @Bean +// public JwtDecoder customJwtDecoder() { +// return token -> { +// try { +// // 使用Nimbus库解析JWT +// JWT jwt = JWTParser.parse(token); +// String typ = jwt.getHeader().getType().toString(); +// // 检查JWT的typ是否为id_token +// if (!typ.equals(jwtTokenType)) { +// throw new AuthenticationServiceException("Invalid token type: " + typ); +// } +// +// JWTClaimsSet claims = jwt.getJWTClaimsSet(); +// +//// Date exp = claims.getExpirationTime(); +//// Date now = new Date(); +// // 检测是否过期 +// if (claims.getExpirationTime().before(new Date())) { +// throw new AuthenticationServiceException("Token has expired"); +// } +// +// // 将JWT转换为Spring Security的Jwt对象 +// return new Jwt(token, jwt.getJWTClaimsSet().getIssueTime().toInstant(), jwt.getJWTClaimsSet().getExpirationTime().toInstant(), jwt.getHeader().toJSONObject(), jwt.getJWTClaimsSet().toJSONObject()); +// } catch (AuthenticationServiceException | ParseException e) { +// // 401 了 +// throw new AuthenticationServiceException(e.getMessage()); +// } +// }; +// +// +// } +// +// @Bean +// public JwtAuthenticationConverter jwtAuthenticationConverter() { +// JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); +// // 提取权限 +// converter.setJwtGrantedAuthoritiesConverter(this::extractAuthoritiesFromJwt); +// +// return converter; +// } +// +// +// private Collection extractAuthoritiesFromJwt(Jwt jwt) { +// // 从JWT的claims中提取角色和权限信息 +// Map claims = jwt.getClaims(); +// List authorities = new ArrayList<>(); +// +// // 提取角色 +// if (claims.containsKey("roles") && claims.get("roles") instanceof List) { +// List roles = (List) claims.get("roles"); +// authorities.addAll(roles.stream() +// .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) +// .toList()); +// } +// +// // 提取权限 +// if (claims.containsKey("permissions") && claims.get("permissions") instanceof List) { +// List permissions = (List) claims.get("permissions"); +// authorities.addAll(permissions.stream() +// .map(SimpleGrantedAuthority::new) +// .toList()); +// } +// +// return authorities; +// } + +} diff --git a/src/main/java/com/example/demo/exception/CustomAuthenticationEntryPoint.java b/src/main/java/com/example/demo/exception/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..2fd693a --- /dev/null +++ b/src/main/java/com/example/demo/exception/CustomAuthenticationEntryPoint.java @@ -0,0 +1,21 @@ +package com.example.demo.exception; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer error=\"invalid_token\", error_description=\"" + authException.getMessage() + "\""); + response.getWriter().write("Unauthorized: " + authException.getMessage()); + } +} diff --git a/src/main/java/com/example/demo/exception/GlobalHttpExceptionHandlerAdvice.java b/src/main/java/com/example/demo/exception/GlobalHttpExceptionHandlerAdvice.java index 45feaa9..b31cba9 100644 --- a/src/main/java/com/example/demo/exception/GlobalHttpExceptionHandlerAdvice.java +++ b/src/main/java/com/example/demo/exception/GlobalHttpExceptionHandlerAdvice.java @@ -34,7 +34,7 @@ public class GlobalHttpExceptionHandlerAdvice { } // get type of e - logger.error("{}, Error: {}", e.getClass().getName(), e.getMessage()); + logger.error("Server Error, {}, Error: {}", e.getClass().getName(), e.getMessage()); response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); diff --git a/src/main/resources/.env.example b/src/main/resources/.env.example index 7e8efab..4bb483f 100644 --- a/src/main/resources/.env.example +++ b/src/main/resources/.env.example @@ -1,3 +1,5 @@ SPRING_DATASOURCE_URL=jdbc:mysql://localhost:3306/demo?serverTimezone=UTC SPRING_DATASOURCE_USERNAME=root -SPRING_DATASOURCE_PASSWORD=Qwerty123... \ No newline at end of file +SPRING_DATASOURCE_PASSWORD=Qwerty123... +JWK_URL=https://auth.leaflow.cn/.well-known/jwks +#JWK_ISSUER_URL=https://auth.leaflow.cn \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 49b93bc..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,25 +0,0 @@ -spring.application.name=demo - -spring.jpa.hibernate.ddl-auto=none - -spring.datasource.url=${SPRING_DATASOURCE_URL} -spring.datasource.username=${SPRING_DATASOURCE_USERNAME} -spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} - -spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -spring.jpa.properties.hibernate.format_sql=true -spring.jpa.show-sql=true - -#spring.shell.script.enabled=true -#spring.shell.interactive.enabled=true - -server.port=8088 - -# 阻止启动时执行 flyway -spring.flyway.baseline-on-migrate=false -spring.flyway.locations=classpath:migrations - -springdoc.api-docs.enabled=true -springdoc.api-docs.path=/v3/api-docs -springdoc.swagger-ui.enabled=true -springdoc.swagger-ui.path=/swagger \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..4c06cfa --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,41 @@ +logging: + level: + org.springframework.web.client.RestTemplate: DEBUG +spring: + application: + name: demo + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + format_sql: true + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + flyway: + baseline-on-migrate: false # 阻止启动时执行 flyway + locations: classpath:migrations + doc: + api-docs: + enabled: true + path: /v3/api-docs + swagger-ui: + enabled: true + path: /swagger + shell: + interactive: + enabled: false + security: + oauth2: + resourceserver: + jwt: + jwk-set-uri: ${JWK_URL} +# issuer-uri: ${JWK_ISSUER_URL} + jackson: + time-zone: PRC +server: + port: 8088 +