springBoot와 JWT를 이용한 인증 서버를 간단히 구축해봤다.
메타코딩님의 "스프링부트 시큐리티 - JWT"를 보고, 이를 바탕으로 스스로 복기하며 학습한 내용을 기록으로 남긴다.
동영상 🎬
JwtAuthorizationFilter가 JwtAuthenticationFilter보다 먼저 실행되게 해야 한다.
동영상만 보면 JwtAuthorizationFilter보다 JwtAuthenticationFilter이 먼저 실행되어야 한다고 오해할듯 하다.
이유는 JwtAuthorizationFilter에서 존재하는 사용자고 유효한 토큰인지 검증 후 검증 실패 시 다음 필터로 넘어가게 하면 안되기 때문이다. (즉, 로그인까지 못가게 해야 함)
소스 💻
application.yml
server:
port: 9090
servlet:
context-path: /
encoding:
charset: UTF-8
enabled: true
force: true
spring:
devtools:
livereload.enabled: true
restart.enabled: true
datasource:
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
url: jdbc:log4jdbc:mysql://localhost:3309/db명
hikari:
username: 사용자
password: 비밀번호
connectionTimeout: 10000
maximum-pool-size: 15
max-lifetime: 600000
readOnly: false
connection-test-query: SELECT 1
jpa:
hibernate:
ddl-auto: update #create update none
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
show-sql: true
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.10'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.lsy'
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-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.auth0:java-jwt:3.10.2'
// Logging
implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
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()
}
java 소스
PrincipalDetails
package com.lsy.jwtvideo.auth;
import com.lsy.jwtvideo.model.User;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
@RequiredArgsConstructor
@Data
// 시큐리티의 컨텍스트에 담을 수 있는 객체인 USerDetails를 구현한 구현체 클래스(이 클래스에 User객체를 담아서 반환)
public class PrincipalDetails implements UserDetails {
private final User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 롤을 GrantedAuthority 타입 리스트로 반환
return user.getRoleList().stream()
.map(role -> (GrantedAuthority) () -> role)
.toList();
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
}
PrincipalDetailsService
package com.lsy.jwtvideo.auth;
import com.lsy.jwtvideo.model.User;
import com.lsy.jwtvideo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository repository;
@Override
// 시큐리티에서 로그인 호출 시 인증하는 메서드
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// username을 db에서 조회 후 값이 있으면 PrincipalDetails에 담아서 반환, 없으면 예외 발생
User findUser = repository.findByUsername(username);
if(findUser == null) {
log.error("User not found username: {}", username);
throw new UsernameNotFoundException("User not found with username: {} " + username);
}
log.info("User found with username : {}", username);
return new PrincipalDetails(findUser);
}
}
CorsConfig
package com.lsy.jwtvideo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
// CORS 설정을 위한 CorsConfiguration 객체 생성
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
// 자바스크립트에서 서버 응답을 처리할 수 있도록 자격 증명 허용 설정 (쿠키나 인증 정보 포함)
config.setAllowCredentials(true);
// 모든 출처(Origin)에서 오는 요청을 허용
config.addAllowedOrigin("*");
// 모든 헤더에 대해 요청을 허용
config.addAllowedHeader("*");
// 모든 HTTP 메서드(GET, POST, PUT 등)에 대해 요청을 허용
config.addAllowedMethod("*");
// "/api/**" 경로로 시작하는 모든 요청에 대해 CORS 정책을 적용
source.registerCorsConfiguration("/api/**", config);
// CORS 필터를 반환하여 요청에 대해 설정된 CORS 정책을 적용
return new CorsFilter(source);
}
}
SecurityConfig
package com.lsy.jwtvideo.config;
import com.lsy.jwtvideo.filter.JwtAuthenticationFilter;
import com.lsy.jwtvideo.filter.JwtAuthorizationFilter;
import com.lsy.jwtvideo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
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.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.filter.CorsFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig {
private final CorsFilter corsFilter;
private final AuthenticationConfiguration authenticationConfiguration;
private final UserRepository repository;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
// 세션 사용하지 않겠다.
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilter(corsFilter)
// form login 사용하지 않겠다.
.formLogin(form -> form.disable())
// http 기본 인증 방식 사용하지 않겠다.
.httpBasic(basic -> basic.disable())
// JWT 필터(토큰 생성)
.addFilter(new JwtAuthenticationFilter(authenticationManager()))
// JWT 토큰 인증
.addFilterBefore(new JwtAuthorizationFilter(repository), JwtAuthenticationFilter.class)
.authorizeHttpRequests(auth ->
auth.requestMatchers("/api/v1/user/**")
.hasAnyRole("USER", "MANAGER", "ADMIN")
.requestMatchers("/api/v1/manager/**")
.hasAnyRole("MANAGER", "ADMIN")
.requestMatchers("/api/v1/admin/**")
.hasAnyRole("ADMIN")
.anyRequest().permitAll()
);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
ApiController
package com.lsy.jwtvideo.controller;
import com.lsy.jwtvideo.model.User;
import com.lsy.jwtvideo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@Slf4j
public class ApiController {
//테스트니까 컨트롤러에서 바로 리포 주입
private final UserRepository repository;
private final PasswordEncoder passwordEncoder;
@PostMapping("join")
public String join(@RequestBody User user) {
user.setPassword(passwordEncoder.encode(user.getPassword()));
user.setRoles(user.getRoles());
repository.save(user);
return "회원가입 완료!";
}
// API 테스트용 메서드
@GetMapping("/api/v1/user")
public String user() {
return "user~~~ 토큰 검증 성공";
}
@GetMapping("/api/v1/manager")
public String manager() {
return "manager";
}
@GetMapping("/api/v1/admin")
public String admin() {
return "admin";
}
}
JwtAuthenticationFilter
package com.lsy.jwtvideo.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lsy.jwtvideo.auth.PrincipalDetails;
import com.lsy.jwtvideo.model.User;
import com.lsy.jwtvideo.utils.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
// JWT인증 받으려면 이 객체 사용해야 함
private final AuthenticationManager authenticationManager;
// /login 호출 시 실행되는 메서드
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// log.info("로그인 요청 왔음~~~~~");
// 요청 파라미터를 읽어서 토큰 객체 생성
try {
ObjectMapper om = new ObjectMapper();
User user = om.readValue(request.getInputStream(), User.class);
log.info("user :: {}", user);
// 토큰 객체 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
Authentication authenticated = authenticationManager.authenticate(authenticationToken);
log.info("authenticated :: {}", authenticated);
//authenticationManager로 토큰 객체를 인증(이 때 사용자가 db에 존재하는지도 체크)
// authenticated값이 있으면 return
return authenticated;
} catch(IOException ie) {
ie.printStackTrace();
}
return null;
}
// 로그인 성공 시 이 메서드 실행됨
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException, ServletException {
// log.info("찍히나?인증되었을거야");
// authResult > 인증된 사용자 정보 담겨 있음
// Principal을 꺼내는데 반환탕비이 Object라서 다운캐스팅을 해서 받는다.
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
// 토큰 생성
//이 함수까지 왔다는 것은 로그인이 되었다는 의미다(올바른 사용자란 의미지)
// JwtTokenUtil을 사용하여 JWT 토큰 생성
String jwtToken = JwtUtil.createToken(principalDetails);
// 응답 헤더에 토큰 추가
response.addHeader("Authorization", "Bearer " + jwtToken);
}
}
JwtAuthorizationFilter
package com.lsy.jwtvideo.filter;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.lsy.jwtvideo.auth.PrincipalDetails;
import com.lsy.jwtvideo.jwt.JwtProperties;
import com.lsy.jwtvideo.model.User;
import com.lsy.jwtvideo.repository.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;
// 토큰 검증
@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final UserRepository repository;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("JWT 토큰 인증 요청~~~");
// 헤더를 읽는다.
String jwtHeader = request.getHeader(JwtProperties.HEADER_STRING);
log.info("jwtHeader :: {}", jwtHeader);
// 로그인, 회원가입 화면은 그냥 필터를 타게 한다.
String requestURI = request.getRequestURI();
if (requestURI.equals("/login") || requestURI.equals("/join")) { // 로그인, 회원가입 경로 예시
filterChain.doFilter(request, response); // 로그인 요청은 JWT 검증을 건너뜁니다.
return;
}
// 유효하지 않은 토큰
if(jwtHeader == null || !jwtHeader.startsWith(JwtProperties.TOKEN_PREFIX)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않은 토큰입니다.");
return;
}
try {
// 토큰만 읽어온다. "Bearer " 이거 우측의 값들
String jwtToken = request.getHeader(JwtProperties.HEADER_STRING).replace("Bearer ", "");
log.info("jwtToken :: {}", jwtToken);
// 토큰 검증
String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build()
.verify(jwtToken)
.getClaim("username")
.asString();
// 토큰 검증 후 username이 반환되면 정상으로 토큰이 검증된 경우다.(아닐 경우 예외발생됨)
if(username != null) {
log.info("정상 서명됨~");
// 토큰은 유효하니까 이제 해당 사용자가 db 테이블에 존재하는지를 체크 한다.
User findUser = repository.findByUsername(username);
PrincipalDetails principalDetails = new PrincipalDetails(findUser);
log.info("principalDetails :: {}", principalDetails.getUsername());
// 토큰객체를 만들어서 시큐리티 컨텍스트에 저장 후 다음 필터로 넘김
Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
log.info("authentication :: {}", authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
} catch (TokenExpiredException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "토큰이 만료되었습니다.");
} catch (JWTVerificationException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않은 토큰입니다.");
} catch (Exception e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "토큰 검증 중 오류가 발생했습니다.");
}
}
}
JwtUtil
package com.lsy.jwtvideo.utils;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.lsy.jwtvideo.auth.PrincipalDetails;
import com.lsy.jwtvideo.jwt.JwtProperties;
import lombok.extern.slf4j.Slf4j;
import java.util.Date;
@Slf4j
public class JwtUtil {
public static String createToken(PrincipalDetails principalDetails) {
//만료시간
Date expiration = new Date(System.currentTimeMillis() + 1000 * 60);
log.info("Token expiration: {}", expiration);
//JWT 생성
return JWT.create()
.withSubject(principalDetails.getUsername())
//만료시간
.withExpiresAt(expiration)
//아이디(번호)
.withClaim("id", principalDetails.getUser().getId()) // 사용자 ID
//사용자이름(id)
.withClaim("username", principalDetails.getUser().getUsername()) // 사용자 이름
//암호화할 개인키
.sign(Algorithm.HMAC512(JwtProperties.SECRET)); // 암호화 키
}
}
JwtProperties
package com.lsy.jwtvideo.jwt;
public class JwtProperties {
public static final String SECRET = "비밀키";
public static final String TOKEN_PREFIX = "Bearer ";
public static final String HEADER_STRING = "Authorization";
}
개인 스터디 기록을 메모하는 공간이라 틀린점이 있을 수 있습니다.
틀린 점 있을 경우 댓글 부탁드립니다.
'IT > Live Coding' 카테고리의 다른 글
Spring Cloud Gateway + API 서버 연동 예제 🚀 (테스트 영상 & 소스코드 포함) (0) | 2025.04.01 |
---|---|
Spring Boot + Jasypt를 이용한 암호화 테스트 🔐 (테스트 영상 & 소스코드 포함) (0) | 2025.03.22 |
[Android, Kotlin] Jetpack Compose와 Glance로 간단한 위젯 구현(영상 & 코드 포함) (0) | 2025.03.16 |
Spring Boot + Redis로 요청 제한 (테스트 영상 & 소스 코드 포함) (1) | 2025.03.09 |
Spring Boot + Redis로 실시간 랭킹 구현 (테스트 영상 & 소스 코드 포함) (1) | 2025.03.08 |
댓글