프로젝트 생성 - 의존성 Check

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() }

실습
Security - session 방식

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
방식이 표준

→ 비밀번호 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는 인증된 사용자의 정보를
SecurityContextHolder
의Authentication
객체에 담아 저장하며, 이 객체는 내부적으로UserDetails
구현체를 가지고 있어야 한다. 그래서User
가UserDetails
를 구현함으로써 인증 정보를 세션에 저장할 수 있게 된다
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()
을 자동 호출하므로,UserService
가UserDetailsService
를 구현
- 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
- ssar login
- 인증 되서
/main
으로 리다이렉션 됨 /main
은인증 O
+USER
권한이 있어야 접근 가능/admin
은ADMIN
권한을 갖고 있지 않아서 권한 오류 발생



- cos login
- 인증 되서
/main
으로 리다이렉션 됨 - cos는
USER
권한을 갖고 있기 때문에/main
에 접근 가능 - cos는
ADMIN
권한도 함께 갖고 있기 때문에/admin
에 접근 가능



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 인증이 자동 활성화되므로 명시적으로 disablehttp.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);
if (jwt == null || !jwt.startsWith(JwtUtil.TOKEN_PREFIX)) { filterChain.doFilter(request, response); return; }
"Bearer "
로 시작하지 않으면 → 그냥 다음 필터로 넘김 (인증 없이 진행)jwt = jwt.replace(JwtUtil.TOKEN_PREFIX, ""); User user = JwtUtil.verify(jwt);
"Bearer "
부분 제거 후 JwtUtil.verify(jwt)
를 통해 JWT 서명 및 만료 검증→ 검증 통과하면
User
객체 생성 (토큰에 들어 있던 정보 기반)Authentication authentication = new UsernamePasswordAuthenticationToken( user, null, user.getAuthorities() ); SecurityContextHolder.getContext().setAuthentication(authentication);
UsernamePasswordAuthenticationToken
을 사용해 인증 객체 생성SecurityContextHolder
에 등록 → 이제 이 요청은 인증된 상태} catch (Exception e) { System.out.println("JWT 오류 : " + e.getMessage()); }
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
- Jwt401Handler - 인증 실패 처리
- 인증이 안 된 사용자가 보호된 리소스에 접근할 때 401 상태코드 + 커스텀 JSON 응답 반환
- JWT가 없거나, 유효하지 않거나, 로그인하지 않은 사용자가 접근할 때 Spring Security가 내부적으로 이 핸들러를 자동 호출
- Jwt403Handler - 인가 실패 처리
- 인증은 되었지만 권한이 부족한 사용자가 접근할 때 403 상태코드 + 커스텀 JSON 응답 반환
- 로그인은 했지만 해당 자원에 접근할 권한이 없는 경우에 실행
→ Spring Security에서 인증/인가 예외 발생 시 사용자에게 커스텀 응답을 내려주는 핸들러
✅ 공통적으로 하는 일
- 응답 Content-Type을
application/json
으로 설정
- 상태 코드(401 또는 403) 설정
RespFilterUtil.fail()
을 사용해 JSON 형식 에러 메시지 생성
- 출력 스트림에 작성 (
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";
Authorization: Bearer <JWT>
형식으로 보냄public static final String TOKEN_PREFIX = "Bearer ";
public static final String SECRET = "메타코딩시크릿키";
public static final Long EXPIRATION_TIME = 1000L * 60 * 60 * 24 * 7;
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...
형태로 리턴
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());
User
객체를 조회null
이면 존재하지 않는 계정if (!bCryptPasswordEncoder.matches(reqDTO.getPassword(), user.getPassword())) throw new RuntimeException("비밀번호가 틀렸습니다");
reqDTO.getPassword()
)와 DB에 저장된 암호화된 비밀번호( user.getPassword()
)를 BCryptPasswordEncoder
로 비교matches()
는 평문 비밀번호와 암호화된 비밀번호를 비교 → 일치하지 않으면 예외 발생String jwtToken = JwtUtil.create(user);
JwtUtil.create(user)
내부에서는 사용자 정보를 담은 서명된 토큰을 발급return jwtToken;
Share article