[Spring Boot] 12. Spring Security

김미숙's avatar
Jul 26, 2025
[Spring Boot] 12. Spring Security

프로젝트 생성 - 의존성 Check

notion image

build.gradle

plugins { id 'java' id 'org.springframework.boot' version '3.5.3' id 'io.spring.dependency-management' version '1.1.7' } group = 'com.metacoding' version = '0.0.1-SNAPSHOT' java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } configurations { compileOnly { extendsFrom annotationProcessor } } repositories { mavenCentral() } dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-mustache' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { useJUnitPlatform() }
notion image
 

실습

Security - session 방식

notion image

Dummy Data

insert into user_tb(roles, username, password, email) values ('USER', 'ssar', '$2a$10$sZvrnV2FZO51MGrG2jTUU.zv3/K/vZFBW5MOYWPTkVeDeoZhH3rai', 'ssar@nate.com'); insert into user_tb(roles, username, password, email) values ('ADMIN,USER', 'cos', '$2a$10$sZvrnV2FZO51MGrG2jTUU.zv3/K/vZFBW5MOYWPTkVeDeoZhH3rai', 'cos@nate.com');

SecurityConfig

package com.metacoding.securityapp.core; 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.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @Configuration public class SecurityConfig { // BCrypt 알고리즘을 사용하여 비밀번호를 단방향 암호화(Hash) @Bean public BCryptPasswordEncoder encodePwd() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin())); // 출처가 같은지 확인 http.csrf(configure -> configure.disable()); // csrf 토큰 비활성화 http.formLogin(form -> form .loginPage("/login-form") // 인증이 안 되면 login-form 으로 이동 .loginProcessingUrl("/login") // SecurityFilter 안의 인증필터가 동작하는 url -> username=password&password=1234 이 형식으로 전송된 데이터만 파싱 (Json 안됨, 키값도 변경되면 안됨 ) -> Authentication 객체에 넣어준다 .defaultSuccessUrl("/main") ); // aware filter // /main - 인증필요, /admin - ADMIN 권한 필요, /user - USER 권한, 인증 필요 http.authorizeHttpRequests( authorize -> authorize .requestMatchers("/main").authenticated() // 인증이 필요한 주소\ .requestMatchers("/user/**").hasRole("USER") .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().permitAll() ); // 페이지별로 권한을 설정하고, 관리자에게 다 주는게 낫다 return http.build(); } }
  • filterChain → Spring Security의 보안 필터 체인을 설정하는 메서드
    • http.headers()X-Frame-Options: 클릭재킹(clickjacking) 방지용 설정 / sameOrigin → 같은 출처일 때만 <iframe> 사용 허용
    • http.csrf() → CSRF 공격 방지 토큰 기능 비활성화 / 일반적으로 API 서버나 개발 초기에는 비활성화하는 경우가 많지만, 운영 환경에서는 반드시 켜야 함
    • http.formLogin() → 폼 기반 로그인 설정
      • loginPage("/login-form"): 로그인 폼을 띄울 페이지 경로
      • loginProcessingUrl("/login"): 로그인 요청을 처리할 URL / 이 URL로 들어온 요청을 Security가 가로채서 인증 처리, 반드시 username, password 이름의 파라미터를 사용해야 함 (JSON X)
      • defaultSuccessUrl("/main"): 로그인 성공 시 이동할 기본 경로
    • http.authorizeHttpRequests) → 접근 권한 설정
      • /main: 로그인(인증)만 하면 접근 가능
      • /user/**: ROLE_USER 권한 필요 (예: 일반 사용자)
      • /admin/**: ROLE_ADMIN 권한 필요 (예: 관리자)
      • anyRequest().permitAll(): 나머지 경로는 모두 허용
      • hasRole("USER")는 내부적으로 ROLE_USER라는 권한을 찾는다
    • return http.build(); → 구성된 필터 체인을 반환하여 Spring Security에 등록
🔍 정리 요약
설정 항목
설명
frameOptions.sameOrigin()
같은 출처 iframe 허용 (ex. h2-console)
csrf.disable()
CSRF 보호 비활성화
formLogin(...)
로그인 폼 설정 (/login-form, 처리 URL /login, 성공 시 /main)
권한 설정
/main은 인증만, /user/**는 USER, /admin/**는 ADMIN 권한 필요
anyRequest().permitAll()
그 외 경로는 누구나 접근 가능
📌 참고
  • formLogin()을 쓰면 /login으로의 POST 요청이 로그인 처리 기본 경로
  • loginPage("/login-form")를 지정하면 /login-form 경로에 사용자가 정의한 로그인 폼 페이지를 만들어야 한다
  • 스프링 시큐리티 6부터는 WebSecurityConfigurerAdapter가 deprecated 되었기 때문에 SecurityFilterChain 방식이 표준
notion image
→ 비밀번호 hash가 안 되있으면 오류남

User

package com.metacoding.securityapp.domain.user; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.ArrayList; import java.util.Collection; @NoArgsConstructor @Getter @Entity @Table(name = "user_tb") public class User implements UserDetails { @GeneratedValue(strategy = GenerationType.IDENTITY) @Id private Integer id; private String username; private String password; private String email; private String roles; // (USER, ADMIN), (USER) (ADMIN) -> 권한을 여러개 줄거면 roles - enum으로 못함 (비정규화) @Builder public User(Integer id, String username, String password, String email, String roles) { this.id = id; this.username = username; this.password = password; this.email = email; this.roles = roles; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { Collection<GrantedAuthority> authorities = new ArrayList<>(); String[] roleList = roles.split(","); for (String role : roleList) { authorities.add(() -> "ROLE_" + role); // ROLE_ 접두사가 있어야 권한을 인식함 } return authorities; } }
  • User → Spring Security의 UserDetails직접 구현한 구조
    • Spring Security가 인증(Authentication)을 처리할 때, 해당 객체(User)를 넣기 위해서 필요
    • Spring Security는 인증된 사용자의 정보를 SecurityContextHolderAuthentication 객체에 담아 저장하며, 이 객체는 내부적으로 UserDetails 구현체를 가지고 있어야 한다. 그래서 UserUserDetails를 구현함으로써 인증 정보를 세션에 저장할 수 있게 된다
  • getAuthorities()UserDetails 인터페이스에서 반드시 구현해야 하는 메서드
    • 현재 사용자가 가지고 있는 권한 목록(ROLE)을 반환하는 역할
    • 즉, "이 사용자가 어떤 역할(ROLE_ADMIN, ROLE_USER 등)을 가지고 있는지"를 Spring Security가 이 메서드를 통해 확인
      • 1. 로그인 시 UserDetailsService.loadUserByUsername()에서 User를 반환
      • 2. Security가 User.getAuthorities()를 호출
      • 3. 해당 사용자의 권한 목록을 GrantedAuthority로 추출
      • 4. 추후 hasRole("USER"), @PreAuthorize("hasRole('ADMIN')") 같은 체크 시 이 목록을 참조

UserController

package com.metacoding.securityapp.controller; import com.metacoding.securityapp.domain.user.User; import com.metacoding.securityapp.domain.user.UserService; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class UserController { private UserService userService; public UserController(UserService userService) { this.userService = userService; } // user 권한이 되야 접근 가능 @GetMapping("/user") public @ResponseBody String user() { return "<h1>user page</h1>"; } @GetMapping("/login-form") public String loginForm() { return "user/login-form"; } // 인증이 되야 접근 가능 @GetMapping("/main") public String main(@AuthenticationPrincipal User user) { // @AuthenticationPrincipal를 사용하면 인증된 객체를 꺼낼 수 있음 System.out.println("username : " + user.getUsername()); return "main"; } @GetMapping("/join-form") public String joinForm() { return "user/join-form"; } @PostMapping("/join") public String join(String username, String password, String email) { userService.회원가입(username, password, email); return "redirect:/login-form"; } }

AdminController

package com.metacoding.securityapp.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class AdminController { // admin 권한만 접근 가능 - 자원의 권한 검증이 아님 // 모든 자원의 권한 검증은 서비스에서만 가능 // 컨트롤러의 책임은 외부의 요청을 잘 받고 응답하는데 책임이 있기 때문에 자원의 권한을 검증할 수 없음 @GetMapping("/admin") public @ResponseBody String adminMain() { return "<h1>admin page</h1>"; } }
  • UserController“/user”은 인증이 된 상태에서 USER 권한이 있어야 접근 가능, “/main”은 인증이 되야 접근 가능
  • AdminController“/admin”은 인증이 된 상태에서 ADMIN 권한이 있어야 접근 가능

UserService

package com.metacoding.securityapp.domain.user; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class UserService implements UserDetailsService { private UserRepository userRepository; private BCryptPasswordEncoder bCryptPasswordEncoder; public UserService(UserRepository userRepository, BCryptPasswordEncoder bCryptPasswordEncoder) { this.userRepository = userRepository; this.bCryptPasswordEncoder = bCryptPasswordEncoder; } @Transactional public void 회원가입(String username, String password, String email) { String encPassword = bCryptPasswordEncoder.encode(password); String roles = "USER"; userRepository.save(roles, username, encPassword, email); } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return userRepository.findByUsername(username); } }
  • 로그인 시 Spring Security가 username으로 사용자를 조회하기 위해 loadUserByUsername()을 자동 호출하므로, UserServiceUserDetailsService를 구현
  • UserDetailsService → Spring Security에서 사용자 정보를 가져오기 위한 표준 인터페이스
    • DB에서 사용자를 조회하고, 인증 처리를 위해 해당 사용자의 정보를 담은 UserDetails 객체를 반환해야 함
    • Spring Security에서 인증(Authentication)을 처리하기 위해서는, 사용자 정보를 DB에서 불러오는 로직이 반드시 필요
    • Spring Security는 로그인 요청이 들어오면:
      • 1. 사용자가 /login으로 로그인 요청을 보냄
      • 2. 내부적으로 AuthenticationManager → UserDetailsService.loadUserByUsername() 호출
      • 3. username 기반으로 사용자 정보(UserDetails)를 찾아와야 함
      • 4. 이를 처리하기 위해 UserService에서 해당 메서드를 구현
  • loadUserByUsername()
    • DB에서 username으로 사용자를 조회
    • 조회 결과로 User 객체(= UserDetails 구현체)를 리턴
    • Spring Security는 이 리턴값을 Authentication 객체에 담아 인증 처리
    • 즉, 로그인 시 username으로 사용자를 찾고 → 그 정보(UserDetails)를 기반으로 Security가 로그인 처리를 수행함
    • ✅ 전체 흐름 정리
      [1] 사용자가 로그인 요청 (POST /login) ↓ [2] SecurityFilter가 요청 가로챔 ↓ [3] AuthenticationManager가 인증 시도 ↓ [4] UserDetailsService.loadUserByUsername("입력한 username") 호출 ↓ [5] DB에서 사용자 조회 → UserDetails 반환 ↓ [6] Security가 내부적으로 비밀번호 체크 후 인증 성공 시 SecurityContext에 저장

UserRepository

package com.metacoding.securityapp.domain.user; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import org.springframework.stereotype.Repository; @Repository public class UserRepository { private EntityManager em; public UserRepository(EntityManager em) { this.em = em; } public void save(String roles, String username, String password, String email) { em.createNativeQuery("insert into user_tb (roles,username, password, email) values (?,?, ?, ?)") .setParameter(1, roles) .setParameter(2, username) .setParameter(3, password) .setParameter(4, email) .executeUpdate(); } public User findByUsername(String username) { try { Query query = em.createNativeQuery("select * from user_tb where username = ?", User.class); query.setParameter(1, username); return (User) query.getSingleResult(); } catch (Exception e) { // 못찾으면 예외가 발생 return null; } } }

Test

  • roles
    • ssar: USER
    • cos: ADMIN, USER
  1. ssar login
    1. notion image
    2. 인증 되서 /main 으로 리다이렉션 됨
    3. /main인증 O + USER 권한이 있어야 접근 가능
    4. notion image
    5. /adminADMIN 권한을 갖고 있지 않아서 권한 오류 발생
    6. notion image
  1. cos login
    1. notion image
    2. 인증 되서 /main 으로 리다이렉션 됨
    3. cos는 USER 권한을 갖고 있기 때문에 /main 에 접근 가능
    4. notion image
    5. cos는 ADMIN 권한도 함께 갖고 있기 때문에 /admin 에 접근 가능
    6. notion image
 

Security - Token 방식

SpringSecurity 순환 참조 오류

build.gradle - mustache 의존성 제거 + JWT 추가

plugins { id 'java' id 'org.springframework.boot' version '3.5.3' id 'io.spring.dependency-management' version '1.1.7' } group = 'com.metacoding' version = '0.0.1-SNAPSHOT' java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } configurations { compileOnly { extendsFrom annotationProcessor } } repositories { mavenCentral() } dependencies { implementation 'com.auth0:java-jwt:4.4.0' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { useJUnitPlatform() }

SecurityConfig

package com.metacoding.securityapp.core; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig { // 비밀번호를 BCrypt hash로 바꾼다 (단방향) @Bean public BCryptPasswordEncoder encodePwd() { return new BCryptPasswordEncoder(); } // 시큐리티 컨텍스트 홀더에 세션 저장할 때 사용하는 클래스 @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 1. iframe 허용 -> mysql로 전환하면 삭제 http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin())); // 출처가 같은지 확인 // 2. csrf 비활성화 -> html 사용 안 할 거니까 http.csrf(csrf -> csrf.disable()); // 3. 세션 비활성화 (STATELESS) -> key 전달 안 해주고, 집에 갈 때 락카를 비워버린다 (세션을 못 쓰는 건 아니다) http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // 4. formLogin 비활성화 (JWT 사용하므로) - UsernamePasswordAuthenticationFilter 발동을 막는다 (JSON 쓸거니까) http.formLogin(form -> form.disable()); // 5. HTTP Basic 인증 비활성화 (BasicAuthenticationFilter 발동을 막기 -UsernamePasswordAuthenticationFilter을 비활성화 하면 자동으로 발동함 ) // BasicAuthenticationFilter -> 가장 기본, 가장 안전한 인증 / 매 요청마다 id, password를 들고 다녀야해서 사용자 입장에서 불편 - id와 pw를 base64로 인코딩 후 암호화해서 인증 필요한 곳에서 RSA로 검증 http.httpBasic(basicLogin -> basicLogin.disable()); // 6. 커스텀 필터 장착 (인가필터 장착) -> 로그인은 controller에서 직접 하기 http.addFilterBefore(new JwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class); // 7. 예외처리 핸들러 등록 ((1)인증,인가가 완료되면 어떻게? (2)예외가 발생하면 어떻게?) http.exceptionHandling(ex -> ex .authenticationEntryPoint(new Jwt401Handler()) .accessDeniedHandler(new Jwt403Handler())); // aware filter // /main - 인증필요, /admin - ADMIN 권한 필요, /user - USER 권한, 인증 필요 http.authorizeHttpRequests( authorize -> authorize .requestMatchers("/user/**").hasRole("USER") .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().permitAll() ); // 페이지별로 권한을 설정하고, 관리자에게 다 주는게 낫다 return http.build(); } }
  • AuthenticationManager
    • 인증 처리 핵심 컴포넌트
    • 직접 로그인 로직 구현할 때 필요
  • filterChain
    • http.csrf(csrf -> csrf.disable()
      • JWT 기반 인증에서는 CSRF 필요 없으므로 CSRF 비활성화
    • http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
      • 세션 비활성화 (STATELESS)
      • 세션을 사용하지 않음 → 매 요청마다 JWT 토큰으로 인증 처리
      • 서버가 사용자 상태를 기억하지 않음 (stateless)
    • http.formLogin(form -> form.disable());
      • 직접 컨트롤러에서 로그인 처리하고 JWT 발급할 예정이므로 기본 로그인 폼 (/login)을 사용하지 않음
    • http.httpBasic(basicLogin -> basicLogin.disable());
      • HTTP Basic 인증 비활성화
      • Basic 인증은 매 요청마다 ID/PW를 실어서 보내는 방식 → JWT 사용 시 불필요
      • formLogin()을 꺼두면 Basic 인증이 자동 활성화되므로 명시적으로 disable
    • http.addFilterBefore(new JwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);
      • 커스텀 필터 추가 (인가 필터)
      • JwtAuthorizationFilter요청에 포함된 JWT 토큰을 파싱해서 인증 객체를 등록
      • Spring Security의 기본 인증 필터 전에 실행되도록 설정
    • http.exceptionHandling(ex -> ex .authenticationEntryPoint(new Jwt401Handler()) .accessDeniedHandler(new Jwt403Handler()));
      • Jwt401Handler: 인증 실패 (미로그인) → 401 Unauthorized 응답
      • Jwt403Handler: 인가 실패 (권한 없음) → 403 Forbidden 응답
 

JwtAuthorizationFilter

package com.metacoding.securityapp.core; import com.metacoding.securityapp.domain.user.User; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; // 인가 필터 // OncePerRequestFilter -> 단 한번만 실행되는 필터 public class JwtAuthorizationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String jwt = request.getHeader(JwtUtil.HEADER); // 요청 헤더에서 JWT 토큰 추출 if (jwt == null || !jwt.startsWith(JwtUtil.TOKEN_PREFIX)) { filterChain.doFilter(request, response); // 토큰이 없으면 다음 필터로 이동하고 return -> 다음 필터로 가다가 터짐 return; } try { jwt = jwt.replace(JwtUtil.TOKEN_PREFIX, ""); User user = JwtUtil.verify(jwt); Authentication authentication = new UsernamePasswordAuthenticationToken( user, null, user.getAuthorities() ); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { System.out.println("JWT 오류 : " + e.getMessage()); } filterChain.doFilter(request, response); } }
  • JwtAuthorizationFilter
    • OncePerRequestFilter 를 상속받음
      • OncePerRequestFilter는 요청당 한 번만 실행되는 필터
    • 1️⃣ 요청 헤더에서 JWT 추출
      • String jwt = request.getHeader(JwtUtil.HEADER);
      • 클라이언트가 보낸 요청 헤더에서 JWT를 꺼냄
    • 2️⃣ JWT가 없거나 잘못된 형식이면 통과
      • if (jwt == null || !jwt.startsWith(JwtUtil.TOKEN_PREFIX)) { filterChain.doFilter(request, response); return; }
      • JWT가 없거나 "Bearer "로 시작하지 않으면 → 그냥 다음 필터로 넘김 (인증 없이 진행)
    • 3️⃣ Bearer 접두사 제거 후 검증
      • jwt = jwt.replace(JwtUtil.TOKEN_PREFIX, ""); User user = JwtUtil.verify(jwt);
      • "Bearer " 부분 제거 후 JwtUtil.verify(jwt)를 통해 JWT 서명 및 만료 검증
        • → 검증 통과하면 User 객체 생성 (토큰에 들어 있던 정보 기반)
    • 4️⃣ 인증 객체 생성 및 등록
      • Authentication authentication = new UsernamePasswordAuthenticationToken( user, null, user.getAuthorities() ); SecurityContextHolder.getContext().setAuthentication(authentication);
      • UsernamePasswordAuthenticationToken을 사용해 인증 객체 생성
      • 이 객체를 SecurityContextHolder에 등록 → 이제 이 요청은 인증된 상태
    • 5️⃣ 예외 발생 시 로그만 출력 (예: 위조된 JWT)
      • } catch (Exception e) { System.out.println("JWT 오류 : " + e.getMessage()); }
      • 잘못된 JWT일 경우 예외 발생 → 로그만 출력하고 다음 필터로 넘어감
    • 6️⃣ 필터 체인 계속 진행
      • filterChain.doFilter(request, response);
      • 현재 필터 작업이 끝났으니, 다음 필터로 넘김
        • → 이후 컨트롤러까지 요청 도달
✅ 요약 흐름도
[요청] ↓ [헤더에서 JWT 추출] ↓ [없거나 잘못됨 → 다음 필터로 넘김] ↓ [유효한 JWT → 사용자 정보 추출] ↓ [Authentication 생성 → SecurityContext 등록] ↓ [요청 계속 진행 (다음 필터, 컨트롤러)]
✅ SecurityContext에 인증 객체를 넣는 이유?
  • 이렇게 해야 Spring Security가 isAuthenticated(), hasRole() 같은 메서드를 통해 인증 여부와 권한을 판단할 수 있기 때문
 

Jwt403Handler

package com.metacoding.securityapp.core; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import java.io.IOException; import java.io.PrintWriter; public class Jwt403Handler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); // 버퍼에 담기 전에 넣어야함 response.setStatus(403); PrintWriter out = response.getWriter(); String responseBody = RespFilterUtil.fail(403, accessDeniedException.getMessage()); out.println(responseBody); out.flush(); } }

Jwt401Handler

package com.metacoding.securityapp.core; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import java.io.IOException; import java.io.PrintWriter; public class Jwt401Handler implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); // 버퍼에 담기 전에 넣어야함 response.setStatus(401); PrintWriter out = response.getWriter(); String responseBody = RespFilterUtil.fail(401, authException.getMessage()); out.println(responseBody); out.flush(); } }
  • Jwt401Handler & Jwt403Handler
    • → Spring Security에서 인증/인가 예외 발생 시 사용자에게 커스텀 응답을 내려주는 핸들러
    • Jwt401Handler - 인증 실패 처리
      • 인증이 안 된 사용자가 보호된 리소스에 접근할 때 401 상태코드 + 커스텀 JSON 응답 반환
      • JWT가 없거나, 유효하지 않거나, 로그인하지 않은 사용자가 접근할 때 Spring Security가 내부적으로 이 핸들러를 자동 호출
    • Jwt403Handler - 인가 실패 처리
      • 인증은 되었지만 권한이 부족한 사용자가 접근할 때 403 상태코드 + 커스텀 JSON 응답 반환
      • 로그인은 했지만 해당 자원에 접근할 권한이 없는 경우에 실행
✅ 공통적으로 하는 일
  1. 응답 Content-Type을 application/json으로 설정
  1. 상태 코드(401 또는 403) 설정
  1. RespFilterUtil.fail()을 사용해 JSON 형식 에러 메시지 생성
  1. 출력 스트림에 작성 (out.println(), flush())

JwtUtil

package com.metacoding.securityapp.core; import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.interfaces.DecodedJWT; import com.metacoding.securityapp.domain.user.User; import java.util.Date; // JWT 토큰 생성 및 검증 유틸리티 public class JwtUtil { public static final String HEADER = "Authorization"; // HTTP 헤더 이름 public static final String TOKEN_PREFIX = "Bearer "; // 토큰 접두사 public static final String SECRET = "메타코딩시크릿키"; // 토큰 서명에 사용될 비밀 키 (강력하게 변경 필요!) public static final Long EXPIRATION_TIME = 1000L * 60 * 60 * 24 * 7; // 7일 (밀리초) // JWT 토큰 생성 public static String create(User user) { String jwt = JWT.create() .withSubject(user.getUsername()) // 토큰의 주체 (여기서는 사용자 이름) .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 토큰 만료 시간 .withClaim("id", user.getId()) // 사용자 ID 클레임 추가 .withClaim("roles", user.getRoles()) // 사용자 역할 클레임 추가 .sign(Algorithm.HMAC512(SECRET)); // 비밀 키로 서명 return TOKEN_PREFIX + jwt; // "Bearer " 접두사 붙여 반환 } // JWT 토큰 검증 및 디코딩 public static User verify(String jwt) { DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(SECRET)) .build() .verify(jwt); // 토큰 검증 Integer id = decodedJWT.getClaim("id").asInt(); String username = decodedJWT.getSubject(); String roles = decodedJWT.getClaim("roles").asString(); return User.builder().id(id).username(username).roles(roles).build(); // 비영속객체 } }
  • JWT(Json Web Token) 기반 인증 시스템의 핵심 유틸리티 클래스
    • JWT 토큰을 생성하고 검증하는 역할을 수행
    • ✅ 전체 구조 요약
      • 기능
        메서드
        JWT 토큰 생성
        create(User user)
        JWT 토큰 검증 및 정보 추출
        verify(String jwt)
    • 상수 필드
      • public static final String HEADER = "Authorization";
      • JWT가 실려오는 HTTP 헤더의 이름
      • 클라이언트가 요청할 때 헤더에 Authorization: Bearer <JWT> 형식으로 보냄
      • public static final String TOKEN_PREFIX = "Bearer ";
      • JWT 앞에 붙는 접두사로 보안 토큰임을 나타냄 (공식 표준)
      • public static final String SECRET = "메타코딩시크릿키";
      • JWT를 서명(Signing) 할 때 사용하는 비밀 키
      • 서버만 알고 있어야 함 (외부 유출 금지)
      • public static final Long EXPIRATION_TIME = 1000L * 60 * 60 * 24 * 7;
      • 토큰의 유효 시간: 현재 설정은 7일 / 단위는 밀리초
    • 1. JWT 토큰 생성 - create(User user)
      • public static String create(User user) { String jwt = JWT.create() .withSubject(user.getUsername()) // 토큰 주제: 사용자 식별자 .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 만료 시간 .withClaim("id", user.getId()) // 사용자 ID 포함 .withClaim("roles", user.getRoles()) // 역할 포함 .sign(Algorithm.HMAC512(SECRET)); // 비밀 키로 서명 return TOKEN_PREFIX + jwt; }
        항목
        설명
        withSubject()
        JWT의 주제(subject) → 일반적으로 고유 사용자명
        withExpiresAt()
        만료 시간 설정 (7일 후)
        withClaim()
        추가적으로 담을 사용자 정보들 (id, roles 등)
        sign()
        HMAC512 알고리즘으로 서명 (SECRET 키 사용)
        📦 최종적으로 Bearer eyJh... 형태로 리턴
    • 2. JWT 토큰 검증 및 정보 추출 - verify(String jwt)
      • public static User verify(String jwt) { DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(SECRET)) .build() .verify(jwt); // 서명 검증, 만료 확인 Integer id = decodedJWT.getClaim("id").asInt(); String username = decodedJWT.getSubject(); String roles = decodedJWT.getClaim("roles").asString(); return User.builder().id(id).username(username).roles(roles).build(); // 비영속 객체 }
        단계
        설명
        JWT.require(...).verify()
        토큰을 검증 (서명 유효성 + 만료 시간 확인)
        getClaim()
        토큰 안에 들어 있던 id, roles 값을 꺼냄
        getSubject()
        JWT 생성 시 설정한 subject(username)을 꺼냄
        User.builder()
        검증된 정보를 기반으로 비영속 User 객체를 만들어 리턴
        🔒 이 User 객체는 DB에서 조회된 게 아니라, JWT 토큰에서 복원한 임시 객체
 

RespFilterUtil

package com.metacoding.securityapp.core; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; public class RespFilterUtil { private static ObjectMapper mapper = new ObjectMapper(); public static String fail(Integer status, String msg) { Resp<?> resp = new Resp(status, msg); try { return mapper.writeValueAsString(resp); // mapper로 직접 json 변환 필요 } catch (JsonProcessingException e) { throw new RuntimeException("json 변환 실패"); } } }
  • 예외 상황에서 JSON 형식의 응답을 생성해주는 유틸리티 클래스
  • 클라이언트에게 JSON 형식의 실패 응답을 문자열로 만들어서 반환
    • 부분
      설명
      Resp<?> resp
      상태코드와 메시지를 담은 응답 객체
      writeValueAsString()
      해당 객체를 직접 JSON 문자열로 변환
      예외 처리
      변환 실패 시 런타임 예외 발생 (예: 순환 참조 등)

Resp

package com.metacoding.securityapp.core; import lombok.Data; @Data public class Resp<T> { private boolean success; private Integer status; private String msg; private T data; // 생성자 오버로딩 public Resp(T data) { this.success = true; this.status = 200; this.msg = "성공"; this.data = data; } public Resp() { this.success = true; this.status = 200; this.msg = "성공"; this.data = null; } public Resp(Integer status, String msg) { this.success = false; this.status = status; this.msg = msg; this.data = null; } }
  • 모든 컨트롤러 응답을 일관된 JSON 포맷으로 통일하기 위한 공통 응답 래퍼 클래스

AdminController

package com.metacoding.securityapp.controller; import com.metacoding.securityapp.core.Resp; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RequestMapping("/admin") @RestController public class AdminController { @GetMapping public Resp<?> adminMain() { return new Resp<>(); } }

UserController

package com.metacoding.securityapp.controller; import com.metacoding.securityapp.core.Resp; import com.metacoding.securityapp.domain.user.UserService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @RequestMapping("/user") @RestController public class UserController { private final UserService userService; @GetMapping public Resp<?> user() { return new Resp<>(); } }

AuthController

package com.metacoding.securityapp.controller; import com.metacoding.securityapp.controller.dto.UserRequest; import com.metacoding.securityapp.core.Resp; import com.metacoding.securityapp.domain.user.UserService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor // final 이 붙어있는 모든 클래스의 생성자를 만들어주는 어노테이션 @RestController // 인증안된 사람이 들어갈 수 있어야 로그인, 회원가입이 가능 public class AuthController { private final UserService userService; /** * username=ssar&password=1234&email=ssar@nate.com * { "username":"ssar", "password":1234, "email":"ssar@nate.com"} */ @PostMapping("/join") public Resp<?> join(@RequestBody UserRequest.Join reqDTO) { userService.회원가입(reqDTO); return new Resp<>(); } @PostMapping("/login") public Resp<?> login(@RequestBody UserRequest.Login reqDTO) { String accessToken = userService.로그인(reqDTO); return new Resp<>(accessToken); } }

UserRequest

package com.metacoding.securityapp.controller.dto; import lombok.Data; public class UserRequest { @Data public static class Login { private String username; private String password; } @Data public static class Join { private String username; private String password; private String email; } }

User

package com.metacoding.securityapp.domain.user; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.ArrayList; import java.util.Collection; @NoArgsConstructor @Getter @Entity @Table(name = "user_tb") public class User implements UserDetails { @GeneratedValue(strategy = GenerationType.IDENTITY) @Id private Integer id; private String username; private String password; private String email; private String roles; // (USER, ADMIN), (USER) (ADMIN) -> 권한을 여러개 줄거면 roles - enum으로 못함 (비정규화) @Builder public User(Integer id, String username, String password, String email, String roles) { this.id = id; this.username = username; this.password = password; this.email = email; this.roles = roles; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { Collection<GrantedAuthority> authorities = new ArrayList<>(); String[] roleList = roles.split(","); for (String role : roleList) { authorities.add(() -> "ROLE_" + role); // ROLE_ 접두사가 있어야 권한을 인식함 } return authorities; } }
  • Spring Security가 인증을 처리하게 하기 위해 User 객체가 UserDetails를 구현
    • → 즉, User 객체를 Spring Security의 인증 시스템에 자연스럽게 통합하기 위해 상속
  • getAuthorities()
    • UserDetails 인터페이스의 필수 구현 메서드
    • 현재 사용자가 가지고 있는 권한(역할)을 Spring Security에게 알려주는 역할
    • 현재 사용자가 가지고 있는 권한 목록을 ROLE_ 형식으로 반환해서 Spring Security가 인가(접근 허용/차단) 판단에 사용할 수 있게 해주는 메서드
    • 코드
      의미
      roles.split(",")
      DB에 저장된 문자열 "USER,ADMIN"을 배열로 나눔
      ROLE_ 접두사
      Spring Security는 권한 이름 앞에 ROLE_이 붙어야 인식 가능
      GrantedAuthority 객체 생성
      권한 목록을 GrantedAuthority 인터페이스 형식으로 반환해야 하므로, 람다식으로 간단히 생성
      반환
      → Security가 이후 hasRole("USER"), hasRole("ADMIN") 같은 권한 체크에 사용함

UserService

package com.metacoding.securityapp.domain.user; import com.metacoding.securityapp.controller.dto.UserRequest; import com.metacoding.securityapp.core.JwtUtil; import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Service public class UserService implements UserDetailsService { private final UserRepository userRepository; private final BCryptPasswordEncoder bCryptPasswordEncoder; @Transactional public void 회원가입(UserRequest.Join reqDTO) { String encPassword = bCryptPasswordEncoder.encode(reqDTO.getPassword()); String roles = "USER"; userRepository.save(roles, reqDTO.getUsername(), encPassword, reqDTO.getEmail()); } public String 로그인(UserRequest.Login reqDTO) { User user = userRepository.findByUsername(reqDTO.getUsername()); if (user == null) throw new RuntimeException("유저네임을 찾을 수 없습니다"); if (!bCryptPasswordEncoder.matches(reqDTO.getPassword(), user.getPassword())) throw new RuntimeException("비밀번호가 틀렸습니다"); // 4. JWT 토큰 생성 String jwtToken = JwtUtil.create(user); return jwtToken; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return userRepository.findByUsername(username); } }
  • 아이디로 사용자 조회 → 비밀번호 비교 → JWT 생성 → 토큰 반환
    • User user = userRepository.findByUsername(reqDTO.getUsername());
    • 사용자가 입력한 username으로 DB에서 User 객체를 조회
    • null이면 존재하지 않는 계정
    • if (!bCryptPasswordEncoder.matches(reqDTO.getPassword(), user.getPassword())) throw new RuntimeException("비밀번호가 틀렸습니다");
    • 사용자가 입력한 비밀번호( reqDTO.getPassword() )와 DB에 저장된 암호화된 비밀번호( user.getPassword() )를 BCryptPasswordEncoder 로 비교
    • matches()는 평문 비밀번호와 암호화된 비밀번호를 비교 → 일치하지 않으면 예외 발생
    • String jwtToken = JwtUtil.create(user);
    • 인증에 성공했으므로, 로그인된 사용자를 기준으로 JWT 토큰을 생성함
    • JwtUtil.create(user) 내부에서는 사용자 정보를 담은 서명된 토큰을 발급
    • return jwtToken;
    • 생성된 JWT 토큰을 클라이언트에 반환
    • 클라이언트는 이 토큰을 이후 요청의 Authorization 헤더에 담아 사용
Share article

parangdajavous