kkokkio - 프로젝트/트러블슈팅

사용자 인증/인가 흐름v1.0을 이해하고 RBAC 적용하기

파란배개 2025. 6. 16. 09:28

💡User Auth Flow v1.0

Controller 계층

  • MemberControllerV1: 회원가입, 정보조회
  • AuthControllerV1: 로그인, 토큰 재발급, 로그아웃, 이메일 인증

Service 계층

1. MemberService:

  • 회원가입(createMember)
    • 회원가입 중복 검사 → 비밀번호 암호화 → 회원 생성
  • 미인증 회원 Redis 임시 저장(siginUpUnverified)Redis Key TTL
    SIGNUP_UNVERIFIED:{email} 5min
  • 회원 정보 조회(getMemberInfo)
    • 쿠키에서 accessToken 추출 후 JWT 검증

2. MailService:

  • 이메일 인증번호 발송(sendAuthCode)
    • 메일 전송 → Redis에 인증번호 임시 저장
    Redis Key TTL
    EMAIL_AUTH:{email} 5min
  • 이메일 인증번호 검증(validationAuthCode)
    • Redis 인증번호 조회 → 이메일 검증 → member.setEmailVerified(true) 처리 → Redis 인증 코드 삭제

3. AuthService:

  • 로그인(login)
    1. 비밀번호 및 이메일 인증 검증
    2. JWT 토큰 및 Refresh 토큰 생성
    3. Redis에 Refresh 토큰 저장Redis Key TTL
      refreshToken:{email} 7day
    4. 쿠키에 JWT(accessToken) & Refresh(refreshToken) 토큰 설정
  • RefreshToken 검증 및 토큰 재발급(refreshToken)
    • 쿠키에서 Refresh 토큰 추출 & payload에서 이메일 추출 → Redis에 저장된 Refresh와 비교
      • 분기 1: 유효하지 않은 토큰
        • 에러 반환 → 재로그인 필요
      • 분기 2: 유효한 토큰
        • 새로운 JWT 토큰 발급
  • 로그아웃(logout)
    • 쿠키에서 AccessToken 추출 → Redis에 AccessToken 블랙리스트로 저장 → Refresh Token 삭제 → 쿠키 삭제Redis Key Prefix TTL
      blackList:{access_token} access_token의 남은 만료기간

Access/Refresh 토큰 및 쿠키 저장 옵션

accessToken

	public void setJwtInCookie(String token, HttpServletResponse response) {
		ResponseCookie cookie = ResponseCookie.from("accessToken", token)
			.httpOnly(true) // 자바스크립트 접근 차단 (XSS 방지)
			.path("/") // 전체 사이트에서 접근 가능
			// Todo: 프론트, 백엔드 도메인 일치 후(Strict)적용
			.sameSite("None") // 외부 사이트 요청 차단 (CSRF 방지)
			.maxAge(Duration.ofMillis(expiration)) // Access Token 만료 시간 : 10분
			.secure(true) // HTTPS 통신 시에만 전송
			.build();

		response.addHeader("Set-Cookie", cookie.toString());
	}

refreshToken

	public void setRefreshTokenInCookie(String token, HttpServletResponse response) {
		ResponseCookie cookie = ResponseCookie.from("refreshToken", token)
			.httpOnly(true)     // 자바스크립트 접근 차단 (XSS 방지)
			.path("/api/v1/auth")      // 인증 경로에서만 접근 가능
			// Todo: 프론트, 백엔드 도메인 일치 후(Strict)적용
			.sameSite("None")   // 외부 사이트 요청 허용 (CORS 환경 대응)
			.maxAge(Duration.ofMillis(refreshTokenExpiration)) // Refresh Token 만료 시간 : 7일
			.secure(true)       // HTTPS 통신 시에만 전송
			.build();

		response.addHeader("Set-Cookie", cookie.toString());
	}

JWT 검증 필터 흐름

<aside> 💡

Spring Security의 인증/인가 흐름을 이해하고 싶다면 이 링크를 참고하면 좋습니다.

</aside>

  1. 필터 등록
    • SecurityConfig에서 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 배치
    • UsernamePasswordAuthenticationFilter 란?
    • Spring Security에서 제공되는 보안 필터 중 하나로 폼 로그인할 때 필요합니다. 폼로그인을 사용하지 않으니 필요는 없어 보입니다. 대신 BasicAuthenticationFilter를 사용하면 보다 명시적으로 기본 인증 필터를 사용한다고 선언할 수 있을 것 같습니다.
    세션을 사용하지 않는 Stateless 모드로 설정되어 있으므로, 모든 인증은 이 필터를 통해 이뤄집니다.
  2. 토큰 추출
    • JwtAuthenticationFilter에서 accessToken을 꺼내서 사용
      • 헤더: Authorization: Bearer <token>
      • 쿠키: 이름이 accessToken인 경우 꺼내서 사용
      // 헤더에서 토큰 추출
      String headerToken = extractTokenFromHeader(request);
      
      // 쿠키에서 토큰 추출
      String cookieToken = extractTokenFromCookie(request);
      
      // 둘 중 하나라도 있으면 있증 처리
      String token = headerToken != null ? headerToken : cookieToken;
      
  3. 블랙리스트 확인
    • Redis에 blackList:{token} 키가 존재하면 로그아웃된 토큰으로 간주하고 401 unauthorized를 응답합니다.
  4. 클레임 검증 및 인증 컨텍스트 설정
    • 만료(ExpiredJwtException)나 서명 불일치 등(JwtException) 시에도 401 또는 400 에러를 반환합니다.
Claims claims = jwtUtils.getClaims(token);
String email = claims.getSubject();
String role = claims.get("role", String.class);
Boolean isVerified = claims.get("isEmailVerified", Boolean.class);

if (email != null && Boolean.TRUE.equals(isVerified)) {
	// UserDetails 객체 생성 (DB에서 사용자 정보 조회)
	UserDetails userDetails = userDetailsService.loadUserByUsername(email);
	List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));

	// UserDetails 기반으로 Authentication 객체 생성
	UsernamePasswordAuthenticationToken authentication =
		new UsernamePasswordAuthenticationToken(userDetails, null, authorities);

	// SecurityContextHolder에 인증 정보 설정
	SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
	response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "이메일 인증이 완료되지 않았습니다.");
	return;
}

5. 세션리스 및 인증/인가 예외 처리

// 세션을 사용하지 않는 Stateless로 설정
.sessionManagement(session -> session
	.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)

// 로그인 관련 예외 처리
.exceptionHandling(ex -> ex
	// **인증 실패:** 미인증 상태로 보호된 엔드포인트 접근 시 401 에러 반환
	.authenticationEntryPoint(customAuthEntryPoint())
	// **권한 거부:** 권한이 부족한 상태로 엔드포인트에 접근했을 때 403 에러 반환
	.accessDeniedHandler(customAccessDeniedHandler())
)

// 엔드포인트별 권한 설정
.authorizeHttpRequests(authorize ->
	authorize
		// — USER 로그인 필요
		.requestMatchers(HttpMethod.POST, "/api/v1/posts/*/comments").authenticated()		// 댓글 작성
		.requestMatchers(HttpMethod.PATCH, "/api/v1/comments/*").authenticated()			// 댓글 수정
		.requestMatchers(HttpMethod.DELETE, "/api/v1/comments/*").authenticated()			// 댓글 삭제
		.requestMatchers(HttpMethod.POST, "/api/v1/comments/*/like").authenticated()		// 댓글 좋아요
		.requestMatchers(HttpMethod.DELETE, "/api/v1/comments/*/like").authenticated()	// 댓글 좋아요 취소

		// 그 외 모든 요청 허용
		.anyRequest().permitAll()
);
  1. JwtAuthenticationFilter

Redis 만료 이벤트 처리

메일 인증이 안된 회원 삭제에 활용

1. 리스너 등록

@Configuration
@Profile("!test")
public class RedisListenerConfig {

	// 만료된 Redis 키 이벤트에 반응하는 RedisMessageListenerContainer 빈
	@Bean
	public RedisMessageListenerContainer keyExpirationListenerContainer(
		RedisConnectionFactory cf,
		RedisExpiredKeyListener expiredListener) {
		RedisMessageListenerContainer container = new RedisMessageListenerContainer();
		// RedisConnectionFactory 설정
		container.setConnectionFactory(cf);
		// expired 이벤트 패턴 구독
		container.addMessageListener(
			expiredListener,
			new PatternTopic("__keyevent@*__:expired"));
		return container;
	}
}

2. 리스너 로직

SIGNUP_UNVERIFIED: 키 만료 시점에 미인증 회원 삭제

@Component
@RequiredArgsConstructor
public class RedisExpiredKeyListener implements MessageListener {
	private final MemberRepository memberRepository;

	// 만료된 Redis 키가 넘어올 때마다 호출되는 콜백 메서드
	@Override
	public void onMessage(Message msg, byte[] pattern) {
		String key = msg.toString();

		// "SIGNUP_UNVERIFIED:"로 시작되는 키만 확인
		if (!key.startsWith("SIGNUP_UNVERIFIED:"))
			return;

		// key에서 email값 분리
		String email = key.split(":", 2)[1];

		// 메일 인증이 안된 회원 삭제
		memberRepository.findByEmail(email).ifPresent(m -> {
			if (!m.isEmailVerified()) {
				memberRepository.delete(m);
			}
		});
	}
}

🎯 Spring Security와 RBAC 적용

메서드 단위 보안 활성화

기본적으로 Spring Security는 요청이 들어올 때 URL 단위로 인증/인가를 검사합니다.

// 엔드포인트별 권한 설정
.authorizeHttpRequests(authorize ->
	authorize
		// — USER 로그인 필요
		.requestMatchers(HttpMethod.POST, "/api/v1/posts/*/comments").authenticated()		// 댓글 작성
		.requestMatchers(HttpMethod.PATCH, "/api/v1/comments/*").authenticated()			// 댓글 수정
		.requestMatchers(HttpMethod.DELETE, "/api/v1/comments/*").authenticated()			// 댓글 삭제
		.requestMatchers(HttpMethod.POST, "/api/v1/comments/*/like").authenticated()		// 댓글 좋아요
		.requestMatchers(HttpMethod.DELETE, "/api/v1/comments/*/like").authenticated()	// 댓글 좋아요 취소

		// 그 외 모든 요청 허용
		.anyRequest().permitAll()
);

하지만 “메서드 단위로 접근 제어”를 하고 싶을 때는 @PreAuthorize 같은 어노테이션 기반 권한 제어를 사용할 수 있습니다.

@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(UUID userId) { … }

@PreAuthorize("hasAnyRole('ADMIN','USER')")
public void moderateContent(Long contentId) { … }

@PreAuthorize("#userId == authentication.principal.id")
public void updateMyInfo(Long userId, ... ) { ... }

이를 가능캐 하려면 @EnableMethodSecurity 어노테이션 설정이 필요합니다.

@EnableMethodSecurity

  • @PreAuthorize, @PostAuthorize, @Secured 등을 사용할 수 있게 해주는 설정용 어노테이션
  • @Configuration 클래스 (여기서는 SecurityConfig) 위에 붙여야 활성화됨
  • prePostEnabled=true 이면 @PreAuthorize, @PostAuthorize 사용 가능
  • securedEnabled=true이면 @Secured 사용 가능

이를 통해 컨트롤러나 서비스 레이어 메서드에 직접 역할 기반 접근 제어를 선언할 수 있습니다.

@Secured - 단순 권한 인증

인자로 받은 권한이 유저에게 있을 경우에만 실행

@Secured("ROLE_ADMIN")
@GetMapping("/info")
public @ResponseBody String info() {
    return "개인정보";
}

@PreAuthorize / @PostAuthorize - 복잡한 인증 과정 필요할 때

@PreAuthorize: 메서드 실행 전에 권한 검사 진행 @PostAuthorize: 메서드 실행 후, 리턴 값을 보고 권한을 검사

표현식 의미

hasRole('USER') ROLE_USER 권한이 있어야 함
hasAnyRole('ADMIN', 'MODERATOR') 둘 중 하나만 있어도 됨
authentication.name == #email SecurityContext에 있는 authentication 객체에 접근
  • 현재 로그인한 사용자의 이메일과 파라미터 비교 | | #user.id == authentication.principal.id | 본인인지 확인 | | permitAll | 모든 접근 허용 | | denyAll | 모든 접근 비허용 | | isAnonymous() | 현재 사용자가 익명(비로그인)인 상태인 경우 true | | isRememberMe() | 현재 사용자가 RememberMe 사용자라면 true | | isAuthenticated() | 현재 사용자가 익명이 아니라면 (로그인 상태라면) true | | isFullyAuthenticated() | 현재 사용자가 익명이거나 RememberMe 사용자가 아니라면 true |

예시: 본인의 정보만 조회 허용

@PostAuthorize("returnObject.owner == authentication.name")
public Document getDocument(Long id) {
    return documentRepository.findById(id).get();
}

커스텀 어노테이션으로 추상화

같은 권한 검사 로직을 여러 메서드에 반복하지 않고 의도를 명확화하기 위해 @PreAuthorize를 커스텀 어노테이션으로 추상화하고자 합니다.

예를 들어, @IsAdmin 어노테이션을 생성할 수 있습니다.

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN')")
public @interface IsAdmin {
}

메서드에서 사용하는 방법:

@IsAdmin
public void deleteUser(...) { ... }
  • 커스텀 어노테이션 레퍼런스(참고 레퍼런스)
    📌 1. 커스텀 어노테이션 기반 API 접근 제어 개요✅ AOP(Aspect-Oriented Programming)를 활용하여 실행 시점에 권한 검증을 수행한다.
    📌 2. 커스텀 어노테이션 생성
  • @Retention(RetentionPolicy.RUNTIME) // 런타임까지 유지
    @Target(ElementType.METHOD) // 메서드에만 적용 가능
    public @interface CheckPermission {
        String value(); // 권한 (ex: "ADMIN", "USER")
    }

  • 📌 3. AOP로 권한 검사 로직 구현
    • 해당 어노테이션이 붙은 메서드가 실행되기 전에, 현재 사용자의 권한을 확인하고 검증한다.
    import org.aspectj.lang.annotation.Aspect;
    import org.aspectj.lang.annotation.Before;
    import org.aspectj.lang.reflect.MethodSignature;
    import org.aspectj.lang.annotation.Pointcut;
    import org.aspectj.lang.JoinPoint;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.context.SecurityContextHolder;
    import org.springframework.stereotype.Component;
    
    import java.lang.reflect.Method;
    
    @Aspect
    @Component
    public class CheckPermissionAspect {
    
        @Pointcut("@annotation(com.example.security.CheckPermission)")
        public void checkPermissionPointcut() {}
    
        @Before("checkPermissionPointcut()")
        public void before(JoinPoint joinPoint) {
            // 현재 로그인된 사용자 정보 가져오기
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication == null || !authentication.isAuthenticated()) {
                throw new SecurityException("인증되지 않은 사용자입니다.");
            }
    
            // 메서드에 붙은 어노테이션 정보 가져오기
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            Method method = signature.getMethod();
            CheckPermission annotation = method.getAnnotation(CheckPermission.class);
    
            if (annotation != null) {
                String requiredRole = annotation.value(); // 필요한 권한
                boolean hasRole = authentication.getAuthorities().stream()
                        .anyMatch(grantedAuthority -> grantedAuthority.getAuthority().equals("ROLE_" + requiredRole));
    
                if (!hasRole) {
                    throw new SecurityException("접근 권한이 없습니다: " + requiredRole);
                }
            }
        }
    }
    
    

    📌 4. 실제 API에 적용하기
  • @RestController
    @RequestMapping("/admin")
    public class AdminController {
    
        @CheckPermission("ADMIN")
        @GetMapping("/dashboard")
        public ResponseEntity<String> getAdminDashboard() {
            return ResponseEntity.ok("Welcome, Admin!");
        }
    
        @CheckPermission("MANAGER")
        @PostMapping("/manage")
        public ResponseEntity<String> manageUsers() {
            return ResponseEntity.ok("User management successful");
        }
    }


    📌 5. Security 설정 (Spring Security 적용)
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.provisioning.InMemoryUserDetailsManager;
    import org.springframework.security.web.SecurityFilterChain;
    
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            http
                .authorizeHttpRequests(auth -> auth
                    .anyRequest().authenticated()
                )
                .formLogin()
                .and()
                .logout();
            return http.build();
        }
    
        @Bean
        public UserDetailsService userDetailsService() {
            UserDetails admin = User.withDefaultPasswordEncoder()
                    .username("admin")
                    .password("password")
                    .roles("ADMIN")
                    .build();
    
            UserDetails manager = User.withDefaultPasswordEncoder()
                    .username("manager")
                    .password("password")
                    .roles("MANAGER")
                    .build();
    
            return new InMemoryUserDetailsManager(admin, manager);
        }
    }
    
    

    📌 6. 실행 및 검증
    1. admin 계정으로 로그인 (ROLE_ADMIN 부여됨)
      • /admin/dashboard 요청 시 200 OK
      • /admin/manage 요청 시 403 Forbidden (MANAGER 권한 필요)
    2. manager 계정으로 로그인 (ROLE_MANAGER 부여됨)
      • /admin/manage 요청 시 200 OK
      • /admin/dashboard 요청 시 403 Forbidden (ADMIN 권한 필요)

    📌 7. 결론Spring Security + AOP 조합으로 구현하면 권한 검증 로직을 공통화할 수 있음이 방식으로 가면 API마다 @PreAuthorize("hasRole('ADMIN')") 같은 코드 없이 더 직관적이고 유연한 권한 제어가 가능해! 🚀
  • 혹시 더 개선하거나 추가하고 싶은 기능이 있으면 알려줘! 😊
  • ✅ 확장하여 여러 개의 권한을 동시에 허용하거나, 동적으로 변경 가능
  • ✅ @CheckPermission("ADMIN") 같은 커스텀 어노테이션을 활용하여 API 접근을 깔끔하게 제어할 수 있음
  • ✅ 테스트 시나리오
  • Spring Security에서 ROLE_ prefix를 자동으로 붙이기 때문에, 위에서 "ROLE_" + requiredRole 방식으로 검증했어.

  • ✅ @CheckPermission("ADMIN") 같은 어노테이션을 만들어서, API마다 접근 권한을 설정할 수 있도록 한다.
  • Spring Security에서 커스텀 어노테이션을 활용하여 API마다 접근 제어하는 방법을 정리해볼게.
  • https://velog.io/@wnguswn7/Project-Custom-Annotaiton을-이용하여-Spring-AOP-프로젝트에-적용하기

 

실무 팁

  • @IsSelfOrModerator, @CanManagePost 등 복합 조건을 묶을 때 사용하거나
  • @CanBanMember, @CanModifyPost 등 이름을 도메인 정책에 가깝게 생성하는 게 좋습니다.

Reference


https://velog.io/@on5949/이거-모르면-Method-Security-쓰지마-세요