Contents
JWT를 활용한 인증 및 REST 서버 전환JWT를 활용한 인증 및 REST 서버 전환
- 기존에 있던 Session 인증을 JWT를 활용한 인증으로 변경
- Session을 사용하지 않기 때문에 UsernamePasswordAuthenticationFilter 비활성화
- 커스텀 필터를 통한 JWT 인가 필터 구현
- 인증은 Aware Filter에서 Authentication에서 그대로 수행
- JWT 인가 필터에서 Authentication에 User 정보 삽입 필요
- 인증 중 token이 없거나 권한이 일치하지 않을 경우 Handler 구현
- RestController 및 Resp 객체를 통한 REST API 서버 전환
Add Dependencies
- JWT 인증을 위한 JWT 라이브러리 추가
implementation 'com.auth0:java-jwt:4.4.0'
Codes
- SecurityConfig.java
- 세션 비활성화 → 토큰으로 인증을 수행하기 때문에 세션에 값을 계속 저장하지 않아도 됨
- formLogin 비활성화 → 화면 사용 X
- 관리자용 웹 사용 시에는 허용해야 함
- HTTP BasicAuthentication 비활성화
- BasicAuthentication : UsernamePasswordAuthentication이 비활성화될 경우 사용
- username, password를 직접 입력하면서 인증하는 방식
- 제일 안전한 방법이지만 사용자의 UX를 많이 해침
- 커스텀 필터 : JWTAuthenticationFilter 작성
- 맨 앞에 부착 : 인가용
- exceptionHandling : 인증 수행 중 발생하는 에러를 catch
@Configuration
public class SecurityConfig {
@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) : 세션 자체는 사용 가능하나, 정보를 계속 저장해두지는 않음
http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 4. 폼 로그인 비활성화 (JWT 사용하므로) : UsernamePasswordAuthenticationFilter 비활성화
http.formLogin(form -> form.disable());
// 5. HTTP Basic 인증 비활성화 : BasicAuthenticationFilter 비활성화
// username, password를 직접 들고 다니기 때문에 제일 안전하나 유저가 이를 관리해야 함 : UX 저하
http.httpBasic(basicLogin -> basicLogin.disable());
// Q. 현재 인증 필터가 없는데 어떻게 인증을 수행할 건가?
// 현재 이 예제에서는 서비스에서 인증 처리 진행 (login 직접 작성)
// 또는 커스텀 필터를 작성해서 추가
// 6. 커스텀 필터 작성
// 인가 필터 장착 : 토큰이 정상적으로 인증되면 AwareFilter로 접근 허용
http.addFilterBefore(new JWTAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);
// 7. 예외 처리 Handler 등록
http.exceptionHandling(ex -> ex
.authenticationEntryPoint(new JWT401Handler())
.accessDeniedHandler(new JWT403Handler()));
/*http.formLogin(form -> form
.loginPage("/login-form")
.loginProcessingUrl("/login") // username=ssar&password=1234
.defaultSuccessUrl("/main"));*/
http.authorizeHttpRequests(
authorize -> authorize
.requestMatchers("/main").authenticated()
.requestMatchers("/user/**").hasRole("USER")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
);
return http.build();
}
}
- JWTAuthorizationFilter.java
- OncePerRequestFilter : 요청 당 1번만 실행되는 필터
- 요청 중에 이전으로 돌아가서 처리하는 로직 발생 시 JWT 인증을 스킵
- JWT 인증 후 Authentication 객체에 User 정보 저장
// 인가 필터 : 로그인 인증 필터 아님
// 단 한 번만 실행되는 필터
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;
}
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);
}
}
- JWTUtil.java
- JWT 토큰 생성 및 검증
// 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();
}
}
- JWT401Handler.java
- jwt가 존재하지 않을 경우 발생하는 에러를 캐치해서 Resp 전송
- Resp 객체 자동 변환 사용 불가 : 따로 만들어줘야 함
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();
}
}
- JWT403Handler.java
- jwt 인증 정보가 다를 경우 발생하는 에러를 캐치해서 Resp 전송
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();
}
}
- RespFilterUtil.java
- Spring 외부의 Filter에서 발생하는 에러를 캐치해서 전송할 Resp 정의
- 자바 내부의 Resp 실패 생성자를 String(JSON)으로 변환
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);
} catch (JsonProcessingException e) {
throw new RuntimeException("json 변환 실패");
}
}
}
- Resp.java
- Spring 내부에서 응답을 할 때 사용할 JSON 변환용 Resp
- static factory method를 사용해서 더욱 직관적으로 사용할 수 있음
- 제네릭 메서드를 사전에 결정할 수 없음 : 이는 더 공부해보고 사용해볼 것
package org.example.securityapp._core;
import lombok.Data;
@Data
public class Resp<T> {
private boolean success;
private Integer status;
private String msg;
private T data;
// static factory method로 만들 때 제네릭 타입 결정 : 공부해볼 것
public Resp() {
this.success = true;
this.status = 200;
this.msg = "성공";
this.data = null;
}
public Resp(T data) {
this.success = true;
this.status = 200;
this.msg = "성공";
this.data = data;
}
public Resp(Integer statusCode, String msg) {
this.success = false;
this.status = statusCode;
this.msg = msg;
this.data = null;
}
}
- AuthController.java
- UserController와 인증 메서드를 구분하기 위해 새로 생성
- AuthController에는 인증을 받지 못한 사람도 접근이 가능해야 함 (회원가입, 로그인 등)
@RequiredArgsConstructor
@RestController
public class AuthController {
private final UserService userService;
@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);
}
}
- AdminController.java / UserController.java
- Controller에서 데이터를 전송하는 RestController로 변환
- 반환하는 데이터 타입도 Resp로 변환
- @RequestMapping을 통해 컨텍스트 구분 가능 : 컨트롤러의 책임 분리
@RequestMapping("/admin")
@RestController
public class AdminController {
@GetMapping
public Resp<?> adminMain() {
return new Resp<>();
}
}
@RequestMapping("/user")
@RestController
public class UserController {
private UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public Resp<?> user() {
return new Resp<>();
}
}
- UserService.java
- 로그인 시 인증 로직 수행
- username으로 User 찾기
- 찾은 User와 reqDTO 내부의 비밀번호 비교 (bCrypt.verify)
- 전부 일치 시 JWT 토큰 생성 후 반환
@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());
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username);
}
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;
}
}
- UserRequest.java
- 데이터 전달 시 사용하는 Request 용 DTO
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;
}
}
Result
- 로그인 성공



- 오류 (토큰 없음 / 권한 없음)

Share article