update
This commit is contained in:
@@ -1,37 +1,20 @@
|
|||||||
package com.tanqidi.survey.config;
|
package com.tanqidi.survey.config;
|
||||||
|
|
||||||
import com.nimbusds.jose.util.JSONObjectUtils;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.core.convert.converter.Converter;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.security.config.Customizer;
|
|
||||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
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.EnableWebSecurity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.core.GrantedAuthority;
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||||
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
|
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
|
|
||||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
|
||||||
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
|
|
||||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.logout.HeaderWriterLogoutHandler;
|
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||||
import org.springframework.security.web.authentication.logout.LogoutHandler;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter;
|
|
||||||
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter.Directive;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
@@ -39,143 +22,85 @@ import java.util.*;
|
|||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@EnableMethodSecurity
|
@EnableMethodSecurity
|
||||||
public class OAuth2LoginSecurityConfig {
|
public class OAuth2LoginSecurityConfig {
|
||||||
private final Logger log = LoggerFactory.getLogger(this.getClass());
|
|
||||||
|
@Bean
|
||||||
|
public JwtAuthenticationConverter jwtAuthenticationConverter() {
|
||||||
|
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
|
||||||
|
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
|
||||||
|
Collection<GrantedAuthority> authorities = new ArrayList<>();
|
||||||
|
Object realmAccess = jwt.getClaim("realm_access");
|
||||||
|
if (realmAccess instanceof Map<?, ?> map) {
|
||||||
|
Object roles = map.get("roles");
|
||||||
|
if (roles instanceof Collection<?> roleList) {
|
||||||
|
for (Object role : roleList) {
|
||||||
|
// System.out.println("ROLE_" + role);
|
||||||
|
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return authorities;
|
||||||
|
});
|
||||||
|
return converter;
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http //
|
http
|
||||||
.sessionManagement(
|
.cors(cors -> cors.configurationSource(request -> {
|
||||||
// https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
// https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#appendix.application-properties.server
|
// String originsProp = env.getProperty("cors.allowed-origins");
|
||||||
Customizer.withDefaults())
|
// 你的前端调试地址,防止出现跨域。可以稍微修改下代码做成从application.properties中读取
|
||||||
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests //
|
String originsProp = "http://localhost:3000,http://localhost:3002,";
|
||||||
.requestMatchers("/login", "/logout").permitAll() //
|
if (!originsProp.isBlank()) {
|
||||||
|
configuration.setAllowedOrigins(Arrays.asList(originsProp.split(",")));
|
||||||
|
}
|
||||||
|
// 只允许常用的安全方法,满足前端常见请求
|
||||||
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||||
|
// 允许所有请求头,便于前端携带token等自定义头
|
||||||
|
configuration.setAllowedHeaders(Arrays.asList("*"));
|
||||||
|
// 允许携带cookie和认证信息,适合需要登录态的前后端分离场景
|
||||||
|
configuration.setAllowCredentials(true);
|
||||||
|
return configuration;
|
||||||
|
}))
|
||||||
|
.sessionManagement(sessionManagement ->
|
||||||
|
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
// .requestMatchers("/login", "/logout", "/error").permitAll()
|
||||||
.requestMatchers("/api/admin/**").hasRole("test-service-admin") //
|
.requestMatchers("/api/admin/**").hasRole("test-service-admin") //
|
||||||
.requestMatchers("/api/user/**").hasRole("test-service-user") //
|
.requestMatchers("/api/user/**").hasAnyRole("test-service-user", "test-service-admin") //
|
||||||
.anyRequest().authenticated()) //
|
.anyRequest().authenticated())
|
||||||
.oauth2Login(oauth2 -> oauth2 //
|
.oauth2ResourceServer(oauth2 -> oauth2
|
||||||
.userInfoEndpoint(userInfo -> userInfo //
|
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) // 设置JWT权限转换器,支持自定义角色映射
|
||||||
.oidcUserService(this.oidcUserService())))
|
.authenticationEntryPoint(unauthorizedEntryPoint()) // 指定自定义401响应,token过期/无效也会返回JSON
|
||||||
.csrf(csrf -> csrf
|
)
|
||||||
.ignoringRequestMatchers("/logout", "/api"))
|
|
||||||
.logout(logout -> logout
|
|
||||||
.addLogoutHandler(new KeycloakLogoutHandler(restTemplateBuilder.build()))
|
|
||||||
.addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(Directive.ALL))))
|
|
||||||
.exceptionHandling(handling -> handling
|
.exceptionHandling(handling -> handling
|
||||||
.accessDeniedHandler((request, response, exception) -> {
|
.accessDeniedHandler(accessDeniedHandler())); // 全局无权限处理
|
||||||
response.setContentType("application/json;charset=UTF-8");
|
|
||||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
|
||||||
response.getWriter().write("{\"code\":403,\"message\":\"没有权限访问该资源\",\"data\":null}");
|
|
||||||
}));
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Autowired
|
@Bean
|
||||||
private RestTemplateBuilder restTemplateBuilder;
|
public AuthenticationEntryPoint unauthorizedEntryPoint() {
|
||||||
|
return (request, response, authException) -> {
|
||||||
// OpenID Connect 1.0 Logout Does Not work for Angular app, since redirect will violate CORS (Reason: CORS header
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
// ‘Access-Control-Allow-Origin’ missing) and OidcClientInitiatedLogoutSuccessHandler ignores Spring Security CORS
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
// https://docs.spring.io/spring-security/reference/servlet/oauth2/login/advanced.html#oauth2login-advanced-oidc-logout
|
Map<String, Object> result = new HashMap<>();
|
||||||
// https://github.com/simasch/vaadin-keycloak/blob/main/src/main/java/ch/martinelli/demo/keycloak/security/KeycloakLogoutHandler.java
|
result.put("code", 401);
|
||||||
private class KeycloakLogoutHandler implements LogoutHandler {
|
result.put("message", "您的访问令牌无效或已过期,请重新登录后再试。"); // 优雅提示
|
||||||
private final RestTemplate restTemplate;
|
result.put("data", null);
|
||||||
|
response.getWriter().write(new ObjectMapper().writeValueAsString(result)); // 返回自定义JSON格式
|
||||||
public KeycloakLogoutHandler(RestTemplate restTemplate) {
|
|
||||||
this.restTemplate = restTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication auth) {
|
|
||||||
logoutFromKeycloak((OidcUser) auth.getPrincipal());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void logoutFromKeycloak(OidcUser user) {
|
|
||||||
// https://access.redhat.com/documentation/en-us/red_hat_single_sign-on/7.6/html-single/securing_applications_and_services_guide/index#logout
|
|
||||||
String endSessionEndpoint = user.getIssuer() + "/protocol/openid-connect/logout";
|
|
||||||
UriComponentsBuilder builder = UriComponentsBuilder //
|
|
||||||
.fromUriString(endSessionEndpoint) //
|
|
||||||
.queryParam("id_token_hint", user.getIdToken().getTokenValue());
|
|
||||||
|
|
||||||
ResponseEntity<String> logoutResponse = restTemplate.getForEntity(builder.toUriString(), String.class);
|
|
||||||
if (logoutResponse.getStatusCode().is2xxSuccessful()) {
|
|
||||||
log.info("Successfully logged out from Keycloak");
|
|
||||||
} else {
|
|
||||||
log.error("Could not propagate logout to Keycloak");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://docs.spring.io/spring-security/reference/servlet/oauth2/login/advanced.html#oauth2login-advanced-map-authorities-oauth2userservice
|
|
||||||
private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
|
|
||||||
final OidcUserService delegate = new OidcUserService();
|
|
||||||
|
|
||||||
return (userRequest) -> {
|
|
||||||
// Delegate to the default implementation for loading a user
|
|
||||||
OidcUser oidcUser = delegate.loadUser(userRequest);
|
|
||||||
|
|
||||||
OAuth2AccessToken accessToken = userRequest.getAccessToken();
|
|
||||||
Collection<GrantedAuthority> mappedAuthorities = new HashSet<>();
|
|
||||||
|
|
||||||
// 1) Fetch the authority information from the protected resource using accessToken
|
|
||||||
// 2) Map the authority information to one or more GrantedAuthority's and add it to mappedAuthorities
|
|
||||||
try {
|
|
||||||
String[] chunks = accessToken.getTokenValue().split("\\.");
|
|
||||||
Base64.Decoder decoder = Base64.getUrlDecoder();
|
|
||||||
String header = new String(decoder.decode(chunks[0]));
|
|
||||||
String payload = new String(decoder.decode(chunks[1]));
|
|
||||||
|
|
||||||
Map<String, Object> claims = JSONObjectUtils.parse(payload);
|
|
||||||
mappedAuthorities = new KeycloakAuthoritiesConverter().convert(claims);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to map Authorities", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Create a copy of oidcUser but use the mappedAuthorities instead
|
|
||||||
oidcUser = new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo(),
|
|
||||||
"preferred_username");
|
|
||||||
|
|
||||||
return oidcUser;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spring OAuth2 uses default Scopes Not Roles for Authorization
|
@Bean
|
||||||
// org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter
|
public AccessDeniedHandler accessDeniedHandler() {
|
||||||
private class KeycloakAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
|
return (request, response, accessDeniedException) -> {
|
||||||
|
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
@Override
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
public Collection<GrantedAuthority> convert(Jwt jwt) {
|
Map<String, Object> result = new HashMap<>();
|
||||||
return convert(jwt.getClaims());
|
result.put("code", 403);
|
||||||
}
|
result.put("message", "权限不足,您无权访问该资源。"); // 简洁优雅提示
|
||||||
|
result.put("data", null);
|
||||||
public Collection<GrantedAuthority> convert(Map<String, Object> claims) {
|
response.getWriter().write(new ObjectMapper().writeValueAsString(result));
|
||||||
Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
|
};
|
||||||
for (String authority : getAuthorities(claims)) {
|
|
||||||
grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + authority));
|
|
||||||
}
|
|
||||||
return grantedAuthorities;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Collection<String> getAuthorities(Map<String, Object> claims) {
|
|
||||||
Object realm_access = claims.get("realm_access");
|
|
||||||
log.info("Retrieved realm_access {}", realm_access);
|
|
||||||
if (realm_access instanceof Map) {
|
|
||||||
Map<String, Object> map = castAuthoritiesToMap(realm_access);
|
|
||||||
Object roles = map.get("roles");
|
|
||||||
if (roles instanceof Collection) {
|
|
||||||
return castAuthoritiesToCollection(roles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Collections.emptyList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
private Map<String, Object> castAuthoritiesToMap(Object authorities) {
|
|
||||||
return (Map<String, Object>) authorities;
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
private Collection<String> castAuthoritiesToCollection(Object authorities) {
|
|
||||||
return (Collection<String>) authorities;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
spring.application.name=survey-service
|
spring.application.name=test-service
|
||||||
server.port=8080
|
server.port=8080
|
||||||
|
|
||||||
## logging
|
## logging
|
||||||
@@ -6,22 +6,12 @@ logging.level.org.springframework.security=INFO
|
|||||||
logging.pattern.console=%d{dd-MM-yyyy HH:mm:ss} %magenta([%thread]) %highlight(%-5level) %logger.%M - %msg%n
|
logging.pattern.console=%d{dd-MM-yyyy HH:mm:ss} %magenta([%thread]) %highlight(%-5level) %logger.%M - %msg%n
|
||||||
|
|
||||||
## keycloak
|
## keycloak
|
||||||
# OAuth2 Log In Spring Boot 2.x Property Mappings
|
|
||||||
# https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html#oauth2login-boot-property-mappings
|
|
||||||
spring.security.oauth2.client.registration.keycloak.client-id=test-service
|
spring.security.oauth2.client.registration.keycloak.client-id=test-service
|
||||||
spring.security.oauth2.client.registration.keycloak.client-secret=e6LMQn67PmoaeXDxCvxiLIfmypjIIygi
|
spring.security.oauth2.client.registration.keycloak.client-secret=?????????????????????
|
||||||
#spring.security.oauth2.client.registration.keycloak.client-authentication-method =
|
|
||||||
# org.springframework.security.oauth2.core.AuthorizationGrantType
|
|
||||||
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
|
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
|
||||||
#spring.security.oauth2.client.registration.keycloak.authorization-grant-type=urn:ietf:params:oauth:grant-type:jwt-bearer
|
|
||||||
#spring.security.oauth2.client.registration.keycloak.redirect-uri =
|
|
||||||
spring.security.oauth2.client.registration.keycloak.scope=openid
|
spring.security.oauth2.client.registration.keycloak.scope=openid
|
||||||
#spring.security.oauth2.client.registration.keycloak.client-name =
|
#spring.security.oauth2.client.provider.keycloak.issuer-uri=https://keycloak.tanqidi.com/realms/tanqidi
|
||||||
|
|
||||||
#spring.security.oauth2.client.provider.keycloak.authorization-uri
|
|
||||||
#spring.security.oauth2.client.provider.keycloak.token-uri
|
|
||||||
#spring.security.oauth2.client.provider.keycloak.jwk-set-uri
|
|
||||||
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://172.31.0.233:31364/realms/tanqidi
|
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://172.31.0.233:31364/realms/tanqidi
|
||||||
#spring.security.oauth2.client.provider.keycloak.user-info-uri
|
|
||||||
#spring.security.oauth2.client.provider.keycloak.user-info-authentication-method
|
|
||||||
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username
|
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username
|
||||||
|
#spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://keycloak.tanqidi.com/realms/tanqidi/protocol/openid-connect/certs
|
||||||
|
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://172.31.0.233:31364/realms/tanqidi/protocol/openid-connect/certs
|
||||||
|
Reference in New Issue
Block a user