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에 인증번호 임시 저장
EMAIL_AUTH:{email} 5min - 이메일 인증번호 검증(validationAuthCode)
- Redis 인증번호 조회 → 이메일 검증 → member.setEmailVerified(true) 처리 → Redis 인증 코드 삭제
3. AuthService:
- 로그인(login)
- 비밀번호 및 이메일 인증 검증
- JWT 토큰 및 Refresh 토큰 생성
- Redis에 Refresh 토큰 저장Redis Key TTL
refreshToken:{email} 7day - 쿠키에 JWT(accessToken) & Refresh(refreshToken) 토큰 설정
- RefreshToken 검증 및 토큰 재발급(refreshToken)
- 쿠키에서 Refresh 토큰 추출 & payload에서 이메일 추출 → Redis에 저장된 Refresh와 비교
- 분기 1: 유효하지 않은 토큰
- 에러 반환 → 재로그인 필요
- 분기 2: 유효한 토큰
- 새로운 JWT 토큰 발급
- 분기 1: 유효하지 않은 토큰
- 쿠키에서 Refresh 토큰 추출 & payload에서 이메일 추출 → Redis에 저장된 Refresh와 비교
- 로그아웃(logout)
- 쿠키에서 AccessToken 추출 → Redis에 AccessToken 블랙리스트로 저장 → Refresh Token 삭제 → 쿠키 삭제Redis Key Prefix TTL
blackList:{access_token} access_token의 남은 만료기간
- 쿠키에서 AccessToken 추출 → Redis에 AccessToken 블랙리스트로 저장 → Refresh Token 삭제 → 쿠키 삭제Redis Key Prefix TTL
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>
- 필터 등록
- SecurityConfig에서 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 배치
- UsernamePasswordAuthenticationFilter 란?
- Spring Security에서 제공되는 보안 필터 중 하나로 폼 로그인할 때 필요합니다. 폼로그인을 사용하지 않으니 필요는 없어 보입니다. 대신 BasicAuthenticationFilter를 사용하면 보다 명시적으로 기본 인증 필터를 사용한다고 선언할 수 있을 것 같습니다.
- 토큰 추출
- JwtAuthenticationFilter에서 accessToken을 꺼내서 사용
- 헤더: Authorization: Bearer <token>
- 쿠키: 이름이 accessToken인 경우 꺼내서 사용
// 헤더에서 토큰 추출 String headerToken = extractTokenFromHeader(request); // 쿠키에서 토큰 추출 String cookieToken = extractTokenFromCookie(request); // 둘 중 하나라도 있으면 있증 처리 String token = headerToken != null ? headerToken : cookieToken;
- JwtAuthenticationFilter에서 accessToken을 꺼내서 사용
- 블랙리스트 확인
- Redis에 blackList:{token} 키가 존재하면 로그아웃된 토큰으로 간주하고 401 unauthorized를 응답합니다.
- 클레임 검증 및 인증 컨텍스트 설정
- 만료(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()
);
- 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. 실행 및 검증- admin 계정으로 로그인 (ROLE_ADMIN 부여됨)
- /admin/dashboard 요청 시 200 OK
- /admin/manage 요청 시 403 Forbidden (MANAGER 권한 필요)
- manager 계정으로 로그인 (ROLE_MANAGER 부여됨)
- /admin/manage 요청 시 200 OK
- /admin/dashboard 요청 시 403 Forbidden (ADMIN 권한 필요)
📌 7. 결론✅ Spring Security + AOP 조합으로 구현하면 권한 검증 로직을 공통화할 수 있음이 방식으로 가면 API마다 @PreAuthorize("hasRole('ADMIN')") 같은 코드 없이 더 직관적이고 유연한 권한 제어가 가능해! 🚀 - admin 계정으로 로그인 (ROLE_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 등 이름을 도메인 정책에 가깝게 생성하는 게 좋습니다.