email로그인과 social 로그인의 사용자들은 입력되는 정보의 차이가 발생
때문에 social 권한을 가진 사용자들은 정보가 채워지기 이전에는 펀딩, 결제관련 api는 호출 불가능하게 권한 설정 (이외 다른 api들은 호출 가능)
| 권한 | 설명 |
|---|---|
| SOCIAL | 소셜 로그인 직후 사용자 정보가 아직 모두 채워지지 않은 상태 |
| USER | 일반 사용자, 모든 API 호출 가능 |
| ADMIN | 관리자 |
결제, 펀딩 관련 api를 호출할 때 권한 별로 다른 error를 반환해줘야함
| 권한 | 결제·펀딩 API 호출 | HTTP Status | Error Code | 메시지 |
|---|---|---|---|---|
| 비로그인 (미인증) | 불가 | 401 | 00401 | No authentication please login |
| SOCIAL (사용자 정보 미완성) | 불가 | 403 | 00403 | Additional data required |
| USER (사용자 정보 완성) | 가능 | — | — | — |
로그인 하지 않은 사용자와 social 로그인을 한 사용자를 구분하여 error 반환
일단 기본 RequestMatcher 설정 코드를 보면
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login/**").permitAll()
)
방식으로 설정하게 된다
여기서 permitAll함수를 customize해서 throw exception을 CustomException 으로 변경할 생각이다
위의 코드에서 auth 변수명으로 받고 있는 파라미터는 AuthorizationManagerRequestMatcherRegistry.class이다
처음 생성되는 곳은
http.authorizeHttpRequests(auth -> { ... })
처음 설정하기 시작하는 함수 부분이다
public HttpSecurity authorizeHttpRequests(
Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry> authorizeHttpRequestsCustomizer)
{
authorizeHttpRequestsCustomizer
.customize(getOrApply(new AuthorizeHttpRequestsConfigurer<>(context)).getRegistry());
return HttpSecurity.this;
}
getOrApply 함수 내부에서 AuthorizeHttpRequestsConfigurer를 생성하고 생성자에서 registry를 생성한다
public AuthorizeHttpRequestsConfigurer(ApplicationContext context) {
this.registry = new AuthorizationManagerRequestMatcherRegistry(context);
// 이후 생략
}
이후 생성된 AuthorizeHttpRequestsConfigurer를 통해 권한을 설정 받는다
.requestMatchers("/login/**").permitAll()
부분의 코드를 뜯어보면
앞에 requestMatchers 부분은 단순 uri 파싱이고
public AuthorizedUrl requestMatchers(String... patterns) {
return requestMatchers(null, patterns);
}
permitAll() 또는 hasRole() 함수 모두 내부 access 함수를 호출하고 있다
public AuthorizationManagerRequestMatcherRegistry permitAll() {
return access(permitAllAuthorizationManager);
}
public AuthorizationManagerRequestMatcherRegistry hasRole(String role) {
return access(withRoleHierarchy(AuthorityAuthorizationManager
.hasAnyRole(AuthorizeHttpRequestsConfigurer.this.rolePrefix, new String[] { role })));
}
access 함수 내부
public AuthorizationManagerRequestMatcherRegistry access(
AuthorizationManager<RequestAuthorizationContext> manager)
{
Assert.notNull(manager, "manager cannot be null");
return (this.not)
? AuthorizeHttpRequestsConfigurer.this.addMapping(this.matchers, AuthorizationManagers.not(manager))
: AuthorizeHttpRequestsConfigurer.this.addMapping(this.matchers, manager);
}
private AuthorizationManagerRequestMatcherRegistry addMapping(
List<? extends RequestMatcher> matchers,
AuthorizationManager<RequestAuthorizationContext> manager)
{
for (RequestMatcher matcher : matchers) {
this.registry.addMapping(matcher, manager);
}
return this.registry;
}
access 함수는 단순히 AuthorizationManager<RequestAuthorizationContext> manager를 받아 registry에 add해주는 역할을 하고 있다
다시 말해 "
.requestMatchers("/login/**").permitAll()"
함수는 AuthorizationManagerRequestMatcherRegistry 에다가 {uri, manager } 를 저장하고 있는 것이다
user 권한을 확인하고 판단을 하는 manager를 분석해서 customize를 시도해 볼 수 있어 보인다
Manager는 user 권한과 미리 입력된 권한을 비교해서 AuthorizationDecision을 반환하는 코드이다
public interface AuthorizationManager<RequestAuthorizationContext> {
@Nullable
@Deprecated
AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object);
// @since 6.4
@Nullable
default AuthorizationResult authorize(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
return check(authentication, object);
}
}
⚠️ 'org.springframework.boot' version '3.4.3' 기준 설명
현 버전 기준 사용되는 함수는 AuthorizationDecision check() 함수이다
쉽게 정리하면 AuthorizationManager는 { user 권한, request uri } 를 주면 boolean (성공 or 실패)를 반환하는 interface이다
spring 기본 AuthorizationManager는 AuthorityAuthorizationManager 이고
.requestMatchers("/api/**").hasRole("USER")
을 실행할 때
public final class AuthorityAuthorizationManager<RequestAuthorizationContext> implements AuthorizationManager<RequestAuthorizationContext> {
private final Set<String> authorities;
// 생성자
private AuthorityAuthorizationManager(String... authorities) {
this.authorities = Set.of(authorities);
}
// factory 함수
public static <T> AuthorityAuthorizationManager<T> hasRole(String role) {
return new AuthorityAuthorizationManager<>(ROLE_PREFIX + authority);
}
// AuthorizationManager override 구현 함수
public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
boolean granted = isGranted(authentication.get(), authorities);
return new AuthorityAuthorizationDecision(granted, AuthorityUtils.createAuthorityList(authorities));
}
}
factory 함수를 통해 생성된다 (AuthoritiesAuthorizationManager 설명 생략)
uri별 필요 권한 설정이 완료된 이후
http
// (다른 설정 생략)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login/**").permitAll()
);
return http.build();
http.build() 함수 호출 때 설정된 정보들을 filter로 변경하여 filter chain에 설정하게 되고 설정해둔 authorizeHttpRequests 또한 AuthorizationFilter가 된다
// class AuthorizeHttpRequestsConfigurer
@Override
public void configure(H http) {
AuthorizationManager<HttpServletRequest> authorizationManager = this.registry.createAuthorizationManager();
AuthorizationFilter authorizationFilter = new AuthorizationFilter(authorizationManager);
http.addFilter(postProcess(authorizationFilter));
}
지금까지 모아둔 registry의 List<{uri, 권한}>을 모두 모아서 한 개의 통합 AuthorizationManager로 만들고
Manager를 가진 AuthorizationFilter를 filter chain에 넣는다
class RequestMatcherDelegatingAuthorizationManager {
private final List<RequestMatcherEntry<AuthorizationManager>> mappings; // List<{uri, 권한}> 을 저장함
@Deprecated
@Override // AuthorizationManager 함수 구현
public AuthorizationDecision check(Supplier<Authentication> authentication, HttpServletRequest request) {
for (RequestMatcherEntry<AuthorizationManager> mapping : this.mappings) {
RequestMatcher matcher = mapping.getRequestMatcher();
MatchResult matchResult = matcher.matcher(request);
// manager의 uri와 request의 uri가 같다면
if (matchResult.isMatch()) {
AuthorizationManager<RequestAuthorizationContext> manager = mapping.getEntry();
return manager.check(authentication,
new RequestAuthorizationContext(request, matchResult.getVariables()));
}
}
// request uri에 맞는 manager가 없다면
return DENY;
}
}
AuthorizationFilter는 간단하게 Manager를 호출하여 확인만 하면 그만이다
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
try {
AuthorizationResult result = this.authorizationManager.authorize(this::getAuthentication, request);
if (result != null && !result.isGranted()) {
throw new AuthorizationDeniedException("Access Denied", result);
}
chain.doFilter(request, response);
}
finally {
request.removeAttribute(alreadyFilteredAttributeName);
}
}
프로젝트 진행 당시 사용했던 version 정보이다
SpringBoot : 3.4.3 (2025/02 release)
SpringSecurity : 6.4.3 (2025/02 release)
위에서 언급했듯이 version 6.4 당시 AuthorizationManager에 대한 수정이 진행되고 있었다
public interface AuthorizationManager<RequestAuthorizationContext> {
@Nullable
@Deprecated
AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object);
// @since 6.4
@Nullable
default AuthorizationResult authorize(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
return check(authentication, object);
}
}
수정의 목표는 이후 Authorization Filter에서 결과값을 boolean으로만 받으면 어떤 이유로 거절되었는지 확인하기 어렵다
때문에 반환값을 바꾸는 과정이 진행되었다
// check함수 반환 값
public class AuthorizationDecision implements AuthorizationResult {
private final boolean granted;
public AuthorizationDecision(boolean granted) {
this.granted = granted;
}
@Override
public boolean isGranted() {
return this.granted;
}
}
// authorize함수 반환 값
// @since 6.3
public interface AuthorizationResult extends Serializable {
boolean isGranted();
}
이후 AuthorizationFilter의 반환값에 결과를 더 풍부하게 나타내기 위함이다
// AuthorizationFilter.doFilter 구현
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) {
String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
// AuthorizationFilter :: 6.0 version ----------------
try {
AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
if (decision != null && !decision.isGranted()) {
throw new AccessDeniedException("Access Denied");
}
chain.doFilter(request, response);
}
finally {
request.removeAttribute(alreadyFilteredAttributeName);
}
// AuthorizationFilter :: 6.4 version -----------
try {
AuthorizationResult result = this.authorizationManager.authorize(this::getAuthentication, request);
this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, result);
if (result != null && !result.isGranted()) {
throw new AuthorizationDeniedException("Access Denied", result);
}
chain.doFilter(request, response);
}
finally {
request.removeAttribute(alreadyFilteredAttributeName);
}
}
6.0 version에서는 AccessDeniedException("Access Denied")에 실패한 원인이 없지만
6.4 version에서는 AuthorizationDeniedException("Access Denied", result)에 실패한 이유가 들어간다
AuthorizationFilter에서 authorize를 호출하고 있지만 Filter에서 가지고 있는 authorizationManager인 RequestMatcherDelegatingAuthorizationManager에서 아직 authorize함수 대신 check함수만을 구현하고 있다
Replace deprecated check::7.0.0-M1 commit 내용이다
public final class RequestMatcherDelegatingAuthorizationManager implements AuthorizationManager<HttpServletRequest> {
// version 6.4
@Deprecated
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, HttpServletRequest request) {
for (RequestMatcherEntry<AuthorizationManager<RequestAuthorizationContext>> mapping : this.mappings) {
RequestMatcher matcher = mapping.getRequestMatcher();
MatchResult matchResult = matcher.matcher(request);
if (matchResult.isMatch()) {
AuthorizationManager<RequestAuthorizationContext> manager = mapping.getEntry();
return manager.check(authentication,
new RequestAuthorizationContext(request, matchResult.getVariables()));
}
}
return DENY;
}
// version 7.0.0-M1
@Override
public AuthorizationResult authorize(Supplier<Authentication> authentication, HttpServletRequest request) {
for (RequestMatcherEntry<AuthorizationManager<RequestAuthorizationContext>> mapping : this.mappings) {
RequestMatcher matcher = mapping.getRequestMatcher();
MatchResult matchResult = matcher.matcher(request);
if (matchResult.isMatch()) {
AuthorizationManager<RequestAuthorizationContext> manager = mapping.getEntry();
return manager.authorize(authentication,
new RequestAuthorizationContext(request, matchResult.getVariables()));
}
}
return DENY;
}
이전 version 6.4 에서는 filter에서 호출을 authorize 함수로 변경했지만
Manager에서 authorize 함수를 override하지 않아 결국 check 함수를 호출하고 있었다
7.0 에서 Manager 구현함수를 check에서 authorize로 변경하면서 upgrade가 적용되게 되었다
이후에도 다른 부분들에서 추가 수정들이 발견되었다
결국 요점은 하나이다
{uri, manager}에서 판단한 error code를 어떻게 filter 이후까지 전달할 것인가
check 함수의 반환 타입은 AuthorizationDecision으로 고정이지만 상속은 가능하다
error code를 들고 있는 ErrorAuthorizationDeniedDecision을 만들어 반환하고 handler에서 꺼내 쓰는 방법이다
public class ErrorAuthorizationDeniedDecision extends AuthorizationDecision {
private final ErrorCode errorCode;
public ErrorAuthorizationDeniedDecision(ErrorCode errorCode) {
super(false);
this.errorCode = errorCode;
}
}
통합 manager는 자식 manager의 반환값을 가공 없이 그대로 돌려주기 때문에
상속한 class를 반환하면 filter까지 그대로 전달된다
delegating authorization manager를 직접 구현해서 자식 manager를 authorize 함수로 호출하게 만드는 방법이다
7.0에 반영되는 RequestMatcherDelegatingAuthorizationManager 수정을 직접 구현하여 미리 반영해버리는 것이다
7.0과 최대한 비슷하게 만들기 위함이다
check 방식은 7.0 version에서 check 함수가 사라지면 수정할 부분이 많아진다
7.0의 RequestMatcherDelegatingAuthorizationManager와 하는 일이 같다
{uri, manager} 목록을 돌면서 uri가 맞는 manager를 찾고, 자식을 authorize 함수로 호출한다
public class DelegatingAuthorizationManager implements AuthorizationManager<HttpServletRequest> {
public DelegatingAuthorizationManager(
List<RequestMatcherEntry<AuthorizationManager<RequestAuthorizationContext>>> mappings
) {
this.mappings = mappings;
}
@Override
public AuthorizationResult authorize(Supplier<Authentication> authentication, HttpServletRequest request) {
for (RequestMatcherEntry<AuthorizationManager<RequestAuthorizationContext>> mapping : this.mappings) {
MatchResult matchResult = mapping.getRequestMatcher().matcher(request);
if (matchResult.isMatch()) {
return mapping.getEntry().authorize(
authentication,
new RequestAuthorizationContext(request, matchResult.getVariables())
);
}
}
return new AuthorizationDecision(false);
}
}
spring security가 filter chain을 만들면
그 이후 filter chain에서 AuthorizationFilter를 찾고, filter 내부 통합 Manager를 리플렉션을 통해 Customized Manager로 바꿔치기 한다
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/api1/**").access(new AuthorizationManager<RequestAuthorizationContext>() {
@Override
public AuthorizationResult authorize(
Supplier<Authentication> authentication,
RequestAuthorizationContext object
) {
Authentication auth = authentication.get();
if (auth == null || auth instanceof AnonymousAuthenticationToken)
return new ErrorCodeAuthorizationResult(ErrorCode.NO_AUTHENTICATED);
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
if (authorities.containsAll(UserRole.USER.getAuthorities()))
return new AuthorizationDecision(true);
if (authorities.containsAll(UserRole.SOCIAL.getAuthorities()))
return new ErrorCodeAuthorizationResult(ErrorCode.NEED_ADDITIONAL_DATA);
throw new CustomException(ErrorCode.UN_KWON, "not social, admin, user, anonymous");
}
@Override
public AuthorizationDecision check(
Supplier<Authentication> authentication,
RequestAuthorizationContext object
) {
throw new UnsupportedOperationException("use authorize()");
}
})
)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// ... 설정
;
SecurityFilterChain filterChain = http.build();
replaceAuthorizationManager(filterChain);
return filterChain;
}
private void replaceAuthorizationManager(SecurityFilterChain chain) throws Exception {
for (Filter filter : chain.getFilters()) {
if (!(filter instanceof AuthorizationFilter authorizationFilter))
continue;
// 통합 Manager 에서 { uri, manager } 목록 꺼내기
AuthorizationManager<HttpServletRequest> origin = authorizationFilter.getAuthorizationManager();
Field mappingsField = origin.getClass().getDeclaredField("mappings");
mappingsField.setAccessible(true);
List<RequestMatcherEntry<AuthorizationManager<RequestAuthorizationContext>>> mappings =
(List<RequestMatcherEntry<AuthorizationManager<RequestAuthorizationContext>>>) mappingsField.get(origin);
// filter 에 Customized Manager 주입
Field managerField = AuthorizationFilter.class.getDeclaredField("authorizationManager");
managerField.setAccessible(true);
managerField.set(authorizationFilter, new DelegatingAuthorizationManager(mappings));
}
}
filter는 spring이 만든 인스턴스를 그대로 사용하고 filter가 들고 있는 Manager만 교체한다
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((request, response, authenticationException) -> {
// 비로그인 사용자 거절
if (authenticationException instanceof InsufficientAuthenticationException)
handlerExceptionResolver.resolveException(request, response, null,
new CustomException(ErrorCode.NO_AUTHENTICATED));
// manager 에서 직접 throw 한 경우
else if (authenticationException instanceof CustomException customException)
handlerExceptionResolver.resolveException(request, response, null, customException);
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
if (accessDeniedException instanceof AuthorizationDeniedException deniedException
&& deniedException.getAuthorizationResult() instanceof ErrorCodeAuthorizationResult result)
handlerExceptionResolver.resolveException(request, response, null,
new CustomException(result.getErrorCode()));
})
)
| 권한 | 결제·펀딩 API 호출 | HTTP Status | Error Code | 메시지 |
|---|---|---|---|---|
| 비로그인 (미인증) | 불가 | 401 | 00401 | No authentication please login |
| SOCIAL (사용자 정보 미완성) | 불가 | 403 | 00403 | Additional data required |
| USER (사용자 정보 완성) | 가능 | — | — | — |
로그인 하지 않은 사용자와 social 로그인을 한 사용자를 구분하여 error 반환
블로그 쓰겠다고 몇일 동안 고생한 이권희들 수고했다