해결된 질문
작성
·
1.2K
1
Spring Authorization 1.0,1 기반으로 개발을 하고 있습니다.
인가코드를 발급 할떄 FormLogin 기본 설정을 사용하면 인가코드가 발급이 되는데 Ajax 로 로그인을 하면 인가코드가 발급되지 않고 있습니다.
디버깅을 해보면 로그인인 후 OAuth2AuthorizationEndpointFilter 는 실행되는데
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!this.authorizationEndpointMatcher.matches(request)) {
filterChain.doFilter(request, response);
return;
}
try {
Authentication authentication = this.authenticationConverter.convert(request);
if (authentication instanceof AbstractAuthenticationToken) {
((AbstractAuthenticationToken) authentication)
.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
Authentication authenticationResult = this.authenticationManager.authenticate(authentication);
if (!authenticationResult.isAuthenticated()) {
// If the Principal (Resource Owner) is not authenticated then
// pass through the chain with the expectation that the authentication process
// will commence via AuthenticationEntryPoint
filterChain.doFilter(request, response);
return;
}
if (authenticationResult instanceof OAuth2AuthorizationConsentAuthenticationToken) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Authorization consent is required");
}
sendAuthorizationConsent(request, response,
(OAuth2AuthorizationCodeRequestAuthenticationToken) authentication,
(OAuth2AuthorizationConsentAuthenticationToken) authenticationResult);
return;
}
this.authenticationSuccessHandler.onAuthenticationSuccess(
request, response, authenticationResult);
} catch (OAuth2AuthenticationException ex) {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Authorization request failed: %s", ex.getError()), ex);
}
this.authenticationFailureHandler.onAuthenticationFailure(request, response, ex);
}
}
FormLogin 적용시에는 authenticationResult의 principal 에 UsernamePasswordAuthenticationToken이 설정되어 인가 코드가 정상적으로 발급되는데
AjaxLogin 적용시에는 authenticationResult의 principal 에 AnonymousAuthenticationToken이 설정되어 인가 코드가 정상적으로 발급되지 않고 403 예외가 발생합니다.
AuthenticationProvider 구현체에서는 정상적으로 토큰을 저장하고 있습니다.
AuthenticationProvider 구현체 소스
@Component
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final CustomUserDetailsService customUserDetailsService;
private final PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if(authentication == null){
throw new InternalAuthenticationServiceException("Authentication is null");
}
LoginRequestDto loginRequestDto = (LoginRequestDto)authentication.getPrincipal();
String password = loginRequestDto.getLoginPassword();
UserAdapter userAdapter = (UserAdapter) customUserDetailsService.loadUserByLoinRequestDto(loginRequestDto);
if (!passwordEncoder.matches(password, userAdapter.getCurrentUser().getLoginPwd())) {
throw new BadCredentialsException("BadCredentialsException");
}
CustomAuthenticationToken result = CustomAuthenticationToken.authenticated(userAdapter.getCurrentUser(), authentication.getCredentials(), userAdapter.getAuthorities());
result.setDetails(authentication.getDetails());
return result;
}
@Override
public boolean supports(Class<?> authentication) {
return CustomAuthenticationToken.class.isAssignableFrom(authentication);
}
}
이외 Custom 소스
Spring Security 설정
@EnableWebSecurity
@RequiredArgsConstructor
@Configuration
public class DefaultSecurityConfig {
private final CustomAuthenticationProvider customAuthenticationProvider;
// private final CustomUserDetailsService customUserDetailsService;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public CustomAuthenticationProcessingFilter customAuthenticationProcessingFilter() throws Exception {
CustomAuthenticationProcessingFilter filter = new CustomAuthenticationProcessingFilter();
// filter.setAuthenticationManager(authenticationManager(null));
filter.setAuthenticationManager(new ProviderManager(customAuthenticationProvider));
// filter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler());
// filter.setAuthenticationFailureHandler(customAuthenticationFailureHandler());
return filter;
}
// @formatter:off
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorizeRequests ->authorizeRequests
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.requestMatchers(new AntPathRequestMatcher("/")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/login/**")).permitAll()
.requestMatchers("/api/login/**").permitAll()
.requestMatchers("/api/registered-client/**").permitAll()
.anyRequest().authenticated()
);
http.addFilterBefore(customAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class);
http.exceptionHandling(httpSecurityExceptionHandlingConfigurer -> httpSecurityExceptionHandlingConfigurer
.authenticationEntryPoint(new CustomLoginAuthenticationEntryPoint())
.accessDeniedHandler(customAccessDeniedHandler())
);
// http.userDetailsService(customUserDetailsService);
// http.formLogin();
http.csrf().disable();
return http.build();
}
// @formatter:on
@Bean
public AccessDeniedHandler customAccessDeniedHandler() {
return new CustomAccessDeniedHandler();
}
@Bean
public AuthenticationSuccessHandler customAuthenticationSuccessHandler() {
return new CustomAuthenticationSuccessHandler();
}
@Bean
public AuthenticationFailureHandler customAuthenticationFailureHandler() {
return new CustomAuthenticationFailureHandler();
}
}
Ajax 로그인 처리 필터 소스
public class CustomAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter {
private final ObjectMapper objectMapper = new ObjectMapper();
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/api/login", HttpMethod.POST.name());
public CustomAuthenticationProcessingFilter() {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
}
public CustomAuthenticationProcessingFilter(AuthenticationManager authenticationManager) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
LoginRequestDto loginRequestDto = objectMapper.readValue(request.getReader(), LoginRequestDto.class);
if(StringUtils.isEmpty(loginRequestDto.getLoginId())||StringUtils.isEmpty(loginRequestDto.getLoginPassword())) {
throw new IllegalStateException("Username or Password is empty");
}
CustomAuthenticationToken authRequest = CustomAuthenticationToken.unauthenticated(loginRequestDto, loginRequestDto.getLoginPassword());
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
return getAuthenticationManager().authenticate(authRequest);
}
}
CustomAuthenticationToken 소스
public class CustomAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
private Object credentials;
public CustomAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
public CustomAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}
public static CustomAuthenticationToken unauthenticated(Object principal, Object credentials) {
return new CustomAuthenticationToken(principal, credentials);
}
public static CustomAuthenticationToken authenticated(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
return new CustomAuthenticationToken(principal, credentials, authorities);
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
}
답변 2
1
네 원인을 찾아 해결하였는데 맨 마지막 처리 부분만 체크해 보시면 되겠습니다.
사실 저도 소스를 분석하면서 스프링 시큐리티가 버전업이 되면서 변경된 사항을 아주 세밀하게는 보지 못한 상황이라 시간이 좀 더 걸린 것 같습니다.
아마 5.x 버전이었다면 발생하지 않을 문제였겠지만 6.x 버전이 되면서 좀 더 엄격해진 규칙 때문에 발생한 내용인 것 같습니다.
가장 중요한 핵심원인은 인증객체 즉 SecurityContext 를 세션에 저장했느냐의 문제입니다.
이전 버전에서는 인증객체를 담고 있는 SecurityContext 를 세션에 담아 전역적인 참조가 가능하게 했던 클래스가 HttpSessionSecurityContextRepository 였고 인증을 하게 되면 기본적으로 SecurityContext 를 세션에 담았습니다.
하지만 최신 버전이 되면서 세션에 담는 역할을 기본적으로 해 주지 않고 필요할 경우 명백하게 저장 하게끔 변경이 되었습니다.
그리고 세션과 요청 범위 중 어디에 담을 것인지도 구분하였습니다.
시큐리티에는 SecurityContextRepository 인터페이스가 있고 이것을 구현하는 구현체 두개를 제공하는데
하나는 세션범위을 관리하는 HttpSessionSecurityContextRepository 클래스와 또 하나는 요청범위를 관리하는 RequestAttributeSecurityContextRepository 입니다.
그리고 이 두개의 클래스들을 DelegatingSecurityContextRepository 가 list 로 담아 가지고 있습니다.
RequestAttributeSecurityContextRepository 는 요청마다 저장되고 삭제되기 때문에 세션에 비해 참조할 수 있는 범위가 작습니다. 기본은 RequestAttributeSecurityContextRepository 으로 생성됩니다.
여기서 기억할 것은 formLogin() api 를 설정하면 생성되는 UsernamePasswordAuthenticationFilter 는 스프링 시큐리티가 초기화되면서 기본적으로 DelegatingSecurityContextRepository 를 설정하게 됩니다.
이 말은 UsernamePasswordAuthenticationFilter 는 HttpSessionSecurityContextRepository 와 RequestAttributeSecurityContextRepository 를 다 가지고 있으며 사용자의 인증받은 SecurityContext 객체를 세션과 요청범위 모두 다 저장할 수 있다는 의미입니다.
인증에 성공한 이후 SecurityContext 를 리포지토리에 저장하게 되는데 위 그림을 보시면 세션에 저장할 수 있는 HttpSessionSecurityContextRepository 가 존재합니다.
하지만 사용자 정의에 의해 커스텀하게 인증 필터 즉 CustomAuthenticationProcessingFilter 를 생성하게 되면 기본적으로는 RequestAttributeSecurityContextRepository 만 생성되기 때문에 인증 받은 SecurityContext 가 세션에 저장되지 않습니다.
해당 소스에서 비동기로 인증을 시도한 다음 성공한 인증객체를 세션에 저장하지 못했기 때문에 인가서버에서 코드를 발급한다음 다시 클라이언트로 리다이렉하는 시점에 시큐리티에서는 이전에 인증 받은 객체를 세션에서 참조할려고 했지만 세션에서 SecurityContext 가 존재하지 않고 Authentication 객체가 null 이기 때문에 시큐리티가 익명객체를 생성해 버린 것입니다.
일단 원인은 이렇고 해결책은 다음과 같습니다. 아주 간단합니다.
CustomAuthenticationProcessingFilter 에도 세션에 SecurityContext 저장할 수 있도록 HttpSessionSecurityContextRepository 추가하면 됩니다
먼저 설정클래스에서 다음과 같이 수정합니다.
@Bean
public CustomAuthenticationProcessingFilter customAuthenticationProcessingFilter(HttpSecurity http) throws Exception{
CustomAuthenticationProcessingFilter filter = new CustomAuthenticationProcessingFilter(http, authenticationConfiguration.getAuthenticationManager());
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
// filter.setAuthenticationManager(new ProviderManager(customAuthenticationProvider));
// filter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler());
// filter.setAuthenticationFailureHandler(customAuthenticationFailureHandler());
return filter;
}
필터에 HttpSecurity 객체를 파라미터로 전달합니다.
http.addFilterBefore(customAuthenticationProcessingFilter(null), UsernamePasswordAuthenticationFilter.class);
오류가 나지 않도록 null 을 설정합니다.
그리고 CustomAuthenticationProcessingFilter 는 다음과 같이 수정합니다.
SecurityContextRepository 의 구현체들을 설정합니다.
public CustomAuthenticationProcessingFilter(HttpSecurity http, AuthenticationManager authenticationManager) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
setSecurityContextRepository(getSecurityContextRepository(http));
}
public CustomAuthenticationProcessingFilter(AuthenticationManager authenticationManager) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
}
SecurityContextRepository getSecurityContextRepository(HttpSecurity http) {
SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
if (securityContextRepository == null) {
securityContextRepository = new DelegatingSecurityContextRepository(
new RequestAttributeSecurityContextRepository(), new HttpSessionSecurityContextRepository());
}
return securityContextRepository;
}
실행해 보면
CustomAuthenticationToken 객체를 세션에 저장하고 있습니다
그리고 인가서버에서도 성공한 인증객체를 잘 참조해 오고 있습니다.
마지막으로 클라이언트에게 리다이렉트 하고 있습니다
여기까지는 성공했는데 정상적이라면 브라우저로 임시코드값과 함께 전달하고 리다이렉션해야 하는데 그 부분이 잘 안되고 있습니다. 그러나 인증은 되었기 때문에 다시 실행하게 되면 코드가 정상적으로 나오고 있습니다.
마직막에 리다이렉트 하는 부분은 체크해 보시기 바랍니다.
0
소스에 DB 접속 정보등 민감한 정보가 있어 소스를 전달 받을 수 있는 이메일 주소등을 념겨 주시면 공유 하겠습니다.
참고로 SavedRequestAwareAuthenticationSuccessHandler 에서 요청을 /oauth2/authorize로 Rediret 할때까지는 Authentication 객체의 principal 은 UserResponseDto 입니다.
일단 주신 소스를 토대로 개발 환경 설정 후 서버 기동하고 간단한 테스트 해 보았습니다.
위와 같이 오류가 나지는 않는데 몇가지 질문을 드립니다.
현재 인가서버에 등록되는 클라이언트 정보가 자동적으로 DB 에 저장되지는 않는 것 같은데 기본은 메모리 방식으로 등록되고 있는 건가요? 아니면 DB 에 테이블을 생성해야 하는 건가요?
클라이언트 등록을 동적으로 할 때 CreateRegisteredClientDto 의 속성을 파라미터로 전달해서 DB 에 저장하는 방식인가요?
실제 실행 하는 과정을 단계별로 적어 주시면 제가 테스트 하는데 더 이해가 잘 될 것 같습니다. 정확하게 어떤 방식과 파라미터로 요청을 보내고 오류가 발생하는 시점은 언제인지와 클라이언트 등록 및 DB 스키마 생성 등의 설명을 구체적으로 부탁드립니다.
인가 과정에서 발생하는 정보는 모두 테이블에 저장됩니다.
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
return new JdbcRegisteredClientRepository(jdbcTemplate);
}
@Bean
public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
return new CustomOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}
@Bean
public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
}
OAuth Client 등록은 Spring Authorization Server(이하 인가 서버) 에서 저장하는 정보 이외에 추가로 필요한 정보가 있어서 RegisteredClientService에서 관리 됩니다.
CustomOAuth2AuthorizationService는 JdbcOAuth2AuthorizationService 구현체가 사용자 정보를 객체로 저장하면 인가코드 발급 후 token endpoint 에서 클라이언트 정보를 가져 올뗴 Jackson 에서 JSON Data 를 파싱 할때 오류가 있어 ObjectMaper 설정을 변경한거 제외하면 JdbcOAuth2AuthorizationService 와 동일합니다.
추가한 소스
this.objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
this.objectMapper.enable(DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT);
this.objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
2. 실행 과정 및 오류 발생하는 시점 : Authorization Code Grant Type으로 인가코드 발급 할떄 오류가 발생합니다.
커스터 마이징 소스는 com.naon.oidc.security 패키지에 있습니다.
profile 은 md 를 사용하시면 됩니다.
application.yml 의 spring.profiles.active 를 md 로 변경하시거나 command lne 으로 전달(IntelliJ를 사용하시면 첨부와 같이 변경)
인가코드 발급 요청
인증 화면
인증 후 CustomAuthenticationProvider(AuthenticationProvider 구현)에서 정상적으로 토큰 발급됨
SavedRequestAwareAuthenticationSuccessHandler 에서 다시 http://localhost:19001/iam/oauth2/authorize?response_type=code&scope=openid&redirect_uri=http://127.0.0.1:8080&client_id=01gsf62vww5p1mza78cdq1xve7&state=some-state&cmpId=C123456789&continue redirect 할떄 까지는 Authentication 객체에는 정상적으로 CustomAuthenticationToken 이 저장됨
OAuth2AuthorizationEndpointFilter에 전달된 Authentication 의 principal에 AnonymousAuthentication 이 전달되어 401 예외가 발상 합니다.
Authentication authenticationResult = this.authenticationManager.authenticate(authentication);
기본 formlogin 울 사용하면 UsernamePasswordAuthenticationToken 이 절달되어 정상적으로 인가 코드가 발급됨
3. DB Skema
oauth2_authorization : 인가서버 제공하는 스키마에서 blob -> varchar 로 변경함
인가서버의 JdbcOAuth2AuthorizationService 구현체에서 BLOB 인자 판단하여 분가 하고 있는데 MariaDB는 blob이 longvarbinary 의 alias 이기 떄문에 blob 조건을 타지 않아 변경하였습니다.
private String getLobValue(ResultSet rs, String columnName) throws SQLException {
String columnValue = null;
ColumnMetadata columnMetadata = columnMetadataMap.get(columnName);
if (Types.BLOB == columnMetadata.getDataType()) {
byte[] columnValueBytes = this.lobHandler.getBlobAsBytes(rs, columnName);
if (columnValueBytes != null) {
columnValue = new String(columnValueBytes, StandardCharsets.UTF_8);
}
} else if (Types.CLOB == columnMetadata.getDataType()) {
columnValue = this.lobHandler.getClobAsString(rs, columnName);
} else {
columnValue = rs.getString(columnName);
}
return columnValue;
}
create table md.oauth2_authorization
(
id varchar(50) not null comment '아이디'
primary key,
registered_client_id varchar(50) not null comment '등록된클라이언트아이디',
principal_name varchar(100) not null comment 'principal이름',
authorization_grant_type varchar(50) not null comment '권한부여유형',
authorized_scopes varchar(1000) null comment '인가된범위들',
attributes varchar(4000) null comment '속성들'
check (json_valid(`attributes`)),
state varchar(50) null comment '상태',
authorization_code_value varchar(200) null comment '인가코드값',
authorization_code_issued_at timestamp(6) default current_timestamp(6) not null on update current_timestamp(6) comment '인가코드발급된시각',
authorization_code_expires_at timestamp(6) default '0000-00-00 00:00:00.000000' not null comment '인가코드만료될시각',
authorization_code_metadata varchar(150) null comment '인가코드메타데이터',
access_token_value varchar(1000) null comment '접근토큰값',
access_token_issued_at timestamp(6) default '0000-00-00 00:00:00.000000' not null comment '접근토큰발급된시각',
access_token_expires_at timestamp(6) default '0000-00-00 00:00:00.000000' not null comment '접근토큰만료될시각',
access_token_metadata varchar(1000) null comment '접근토큰만료될시각',
access_token_type varchar(20) null comment '접근토큰유형',
access_token_scopes varchar(1000) null comment '접근토큰범위들',
oidc_id_token_value varchar(1000) null comment 'OpenIDConnect아이디토큰값',
oidc_id_token_issued_at timestamp(6) default '0000-00-00 00:00:00.000000' not null comment 'OpenIDConnect아이디토큰발급된시각',
oidc_id_token_expires_at timestamp(6) default '0000-00-00 00:00:00.000000' not null comment 'OpenIDConnect아이디토큰만료될시각',
oidc_id_token_metadata varchar(4000) null comment 'OpenIDConnect아이디토큰메타데이터',
refresh_token_value varchar(200) null comment '리프레쉬토큰값',
refresh_token_issued_at timestamp(6) default '0000-00-00 00:00:00.000000' not null comment '리프레쉬토큰발급된시각',
refresh_token_expires_at timestamp(6) default '0000-00-00 00:00:00.000000' not null comment '리프레쉬토큰만료될시각',
refresh_token_metadata varchar(150) null comment '리프레쉬토큰메타데이터'
)
comment 'OAuth2권한';
oauth2_authorization_consent, oauth2_registered_client 인가 서버에서 제공하는 스카마와 동일
클라이언트
등록 : 질문 하신 내용 처럼 CreateRegisteredClientDto 로 전달 등록합니다.
URI : http://localhost:19001/iam/api/registered-client
Method : POST
{
"id": "20000000",
"clientName": "테스트",
"redirectUris": [
"http://127.0.0.1:8080",
"http://127.0.0.1:9000"
]
}
ORG_PERSON
ORG_EMPLOYEE
CMM_I18N
ORG_MY_JOB
LIC_LICENSE_USER
SECU_CERT_PSN_CFG
테이블이 없어서 오류가 나고 있는데 생성 스크립트 전달 가능할까요?.
요청하는 테이블 스키마는 이메일로 공유 하였습니다.
최근 Spring Seurity 자료를 보면 커스텀 필터등록시 AbstractHttpConfigurer를 상속하여 등록도록 설명되어 있어 필터를 등록하는 방식이 변경된것이 원인 일 수 있어 변경하였으나 동일한 현상이 발생 합니다.
https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter
public class CustomSecurityFilterManager extends AbstractHttpConfigurer<CustomSecurityFilterManager, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
builder.addFilterBefore(new CustomAuthenticationProcessingFilter(authenticationManager), UsernamePasswordAuthenticationFilter.class);
super.configure(builder);
}
}
.......
// 필터 적용
http.apply(new CustomSecurityFilterManager());
감사 합니다.
필터 등록은 null 설정하지 않고 HttpSecity 객체로 설정 하였습니다.