DevZip 프로젝트의 보안: 관리자 권한 및 접근 제어 시스템

개요

DevZip 프로젝트는 Spring Boot 3.3.1 기반의 웹 분석 플랫폼으로, JWT 기반 인증과 Spring Security를 활용한 세밀한 관리자 접근 제어 시스템을 구현했습니다. 이 글에서는 실제 Spring Boot 환경에서 구현된 보안 메커니즘과 설계 철학을 소개합니다.

Spring Security 기반 인증 시스템

기술 스택

  • Spring Boot 3.3.1
  • Spring Security: 인증 및 권한 부여
  • JWT (JSON Web Token): 상태 없는 인증
  • JPA/Hibernate: 사용자 엔티티 관리
  • MySQL: 사용자 정보 저장

JWT 설정 (application.properties)

# JWT 설정
app.jwt.secret=${JWT_SECRET_KEY:defaultSecretKey}
app.jwt.expiration=86400000
app.cors.allowed-origins=http://localhost:3000,https://devzip.cloud

사용자 엔티티 구현

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(unique = true, nullable = false)
    private String username;
    
    @Column(nullable = false)
    private String password;
    
    @Column(unique = true, nullable = false)
    private String email;
    
    @Enumerated(EnumType.STRING)
    private Role role = Role.USER;
    
    // getters, setters, constructors
}

public enum Role {
    USER, ADMIN
}

Spring Security 설정

SecurityConfig 클래스

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    
    @Autowired
    private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    
    @Autowired
    private JwtRequestFilter jwtRequestFilter;
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/log/event").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            );
        
        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }
}

JWT 토큰 관리

JWT 유틸리티 클래스

@Component
public class JwtUtil {
    
    private String secret;
    private long jwtExpiration;
    
    public JwtUtil(@Value("${app.jwt.secret}") String secret,
                   @Value("${app.jwt.expiration}") long jwtExpiration) {
        this.secret = secret;
        this.jwtExpiration = jwtExpiration;
    }
    
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        // 사용자 역할 정보를 JWT 클레임에 포함
        claims.put("role", userDetails.getAuthorities().iterator().next().getAuthority());
        return createToken(claims, userDetails.getUsername());
    }
    
    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + jwtExpiration))
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }
    
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }
}

JWT 인증 필터

@Component
public class JwtRequestFilter extends OncePerRequestFilter {
    
    @Autowired
    private UserDetailsService userDetailsService;
    
    @Autowired
    private JwtUtil jwtUtil;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                  HttpServletResponse response, 
                                  FilterChain chain) throws ServletException, IOException {
        
        final String requestTokenHeader = request.getHeader("Authorization");
        
        String username = null;
        String jwtToken = null;
        
        if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
            jwtToken = requestTokenHeader.substring(7);
            try {
                username = jwtUtil.getUsernameFromToken(jwtToken);
            } catch (IllegalArgumentException e) {
                logger.error("JWT Token 획득 실패", e);
            } catch (ExpiredJwtException e) {
                logger.error("JWT Token 만료됨", e);
            }
        }
        
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
            
            if (jwtUtil.validateToken(jwtToken, userDetails)) {
                UsernamePasswordAuthenticationToken authToken = 
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        chain.doFilter(request, response);
    }
}

관리자 API 컨트롤러

AuthController - 인증 관련 엔드포인트

@RestController
@RequestMapping("/api/auth")
@CrossOrigin(origins = {"http://localhost:3000", "https://devzip.cloud"})
public class AuthController {
    
    @Autowired
    private AuthenticationManager authenticationManager;
    
    @Autowired
    private JwtUtil jwtUtil;
    
    @Autowired
    private UserService userService;
    
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
        try {
            authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                    loginRequest.getUsername(), 
                    loginRequest.getPassword())
            );
        } catch (BadCredentialsException e) {
            throw new BadCredentialsException("잘못된 자격 증명", e);
        }
        
        final UserDetails userDetails = userService
            .loadUserByUsername(loginRequest.getUsername());
        final String token = jwtUtil.generateToken(userDetails);
        
        return ResponseEntity.ok(new JwtResponse(token));
    }
    
    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody SignUpRequest signUpRequest) {
        if (userService.existsByUsername(signUpRequest.getUsername())) {
            return ResponseEntity.badRequest()
                .body(new MessageResponse("오류: 사용자명이 이미 사용 중입니다!"));
        }
        
        if (userService.existsByEmail(signUpRequest.getEmail())) {
            return ResponseEntity.badRequest()
                .body(new MessageResponse("오류: 이메일이 이미 사용 중입니다!"));
        }
        
        // 새 사용자 계정 생성 (기본 역할: USER)
        User user = new User(signUpRequest.getUsername(),
                           signUpRequest.getEmail(),
                           passwordEncoder.encode(signUpRequest.getPassword()));
        user.setRole(Role.USER);
        
        userService.save(user);
        
        return ResponseEntity.ok(new MessageResponse("사용자가 성공적으로 등록되었습니다!"));
    }
}

AdminController - 관리자 전용 기능

@RestController
@RequestMapping("/api/admin")
@PreAuthorize("hasRole('ADMIN')")
@CrossOrigin(origins = {"http://localhost:3000", "https://devzip.cloud"})
public class AdminController {
    
    @Autowired
    private UserService userService;
    
    @Autowired
    private EventService eventService;
    
    @GetMapping("/users")
    public ResponseEntity<List<User>> getAllUsers() {
        List<User> users = userService.findAll();
        return ResponseEntity.ok(users);
    }
    
    @PostMapping("/users")
    public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
        if (userService.existsByUsername(request.getUsername())) {
            return ResponseEntity.badRequest()
                .body(new MessageResponse("사용자명이 이미 존재합니다."));
        }
        
        User user = new User();
        user.setUsername(request.getUsername());
        user.setEmail(request.getEmail());
        user.setPassword(passwordEncoder.encode(request.getPassword()));
        user.setRole(request.getRole()); // ADMIN 또는 USER
        
        userService.save(user);
        
        return ResponseEntity.ok(new MessageResponse("사용자가 성공적으로 생성되었습니다."));
    }
    
    @GetMapping("/events")
    public ResponseEntity<Page<Event>> getEvents(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "50") int size) {
        
        Pageable pageable = PageRequest.of(page, size);
        Page<Event> events = eventService.findAll(pageable);
        
        return ResponseEntity.ok(events);
    }
    
    @GetMapping("/stats")
    public ResponseEntity<AdminStats> getStats() {
        AdminStats stats = new AdminStats();
        stats.setTotalUsers(userService.count());
        stats.setTotalEvents(eventService.count());
        stats.setActiveUsers(userService.countActiveUsers());
        
        return ResponseEntity.ok(stats);
    }
}

UserDetailsService 구현

CustomUserDetailsService

@Service
public class CustomUserDetailsService implements UserDetailsService {
    
    @Autowired
    private UserRepository userRepository;
    
    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username));
        
        return UserPrincipal.create(user);
    }
}

public class UserPrincipal implements UserDetails {
    private Long id;
    private String username;
    private String email;
    private String password;
    private Collection<? extends GrantedAuthority> authorities;
    
    public static UserPrincipal create(User user) {
        List<GrantedAuthority> authorities = List.of(
            new SimpleGrantedAuthority("ROLE_" + user.getRole().name())
        );
        
        return new UserPrincipal(
            user.getId(),
            user.getUsername(),
            user.getEmail(),
            user.getPassword(),
            authorities
        );
    }
    
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }
    
    @Override
    public boolean isAccountNonExpired() { return true; }
    
    @Override
    public boolean isAccountNonLocked() { return true; }
    
    @Override
    public boolean isCredentialsNonExpired() { return true; }
    
    @Override
    public boolean isEnabled() { return true; }
}

보안 설정 및 CORS

CORS 설정 (application.properties)

# CORS 설정
app.cors.allowed-origins=http://localhost:3000,https://devzip.cloud
app.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
app.cors.allowed-headers=*
app.cors.allow-credentials=true

# 세션 보안
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.same-site=strict

# 로깅 설정
logging.level.org.springframework.web=DEBUG
logging.level.org.springframework.security=DEBUG
logging.level.com.hoooon22.devzip=DEBUG

결론

DevZip 프로젝트의 Spring Boot 기반 보안 시스템은 다음과 같은 특징을 갖습니다:

핵심 보안 원칙

  • JWT 기반 무상태 인증: 확장 가능한 토큰 기반 인증 시스템
  • 역할 기반 접근 제어: @PreAuthorize 어노테이션을 활용한 메서드 레벨 보안
  • BCrypt 암호화: 안전한 비밀번호 해싱 알고리즘 사용
  • CORS 정책: 명시적 도메인 허용으로 XSS 공격 방지

기술적 구현 요소

  • Spring Security 6: 최신 보안 프레임워크 활용
  • JPA/Hibernate: 사용자 엔티티 영속성 관리
  • Method Security: 세밀한 권한 제어
  • Custom UserDetailsService: 비즈니스 요구사항에 맞는 인증 로직

운영 환경 고려사항

  • 환경 변수 기반 설정: 민감한 정보의 안전한 관리
  • 페이지네이션 제한: 대용량 데이터 처리 시 성능 최적화
  • 상세한 로깅: 보안 이벤트 추적 및 감사

지속적인 보안 업데이트와 모니터링을 통해 안전한 웹 분석 플랫폼을 제공합니다.