<개발환경>
💻 Apple M1 Pro (macos14.5 on arm64)
🛠️ IDE: IntelliJ
🏁 Language: JAVA17
🔗 Framework: Springboot 3.3.0
⚙️ Project: Gradle
🗄️ Database: MySQL 8.3.0 Homebrew
🔐 Spring Security 6.3.0
JWT (JSON Web Token) 는 무엇인가요?
JWT(JSON Web Token)는 JSON 객체를 사용하여 두 개체 간 정보를 안전하게 전송하기 위한 컴팩트하고 자가 포함된 방식입니다. JWT는 보통 인증과 권한 부여에 사용됩니다.
프로젝트 설정
의존성 추가
- build.gradle
// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5' // JWT 표준 API
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' // 실제 구현체 (JWT 생성,서명,검증 기능)
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JWT 페이로드를 JSON 형식으로 처리
프로젝트 구조
코드 구현
SecurityConfig - Jwt 필터 추가
addFilterBefore 메서드를 사용하여 JWT 필터(JwtRequestFilter)가 인증을 처리하는 필터 UsernamePasswordAuthenticationFilter 보다 먼저 실행되도록 합니다.
이렇게 하면 JWT 필터가 먼저 실행되어 JWT를 검증한 후, UsernamePasswordAuthenticationFilter가 실행되도록 보장됩니다.
- SecurityConfig.java
@Configuration // Spring 설정 클래스임
@EnableWebSecurity // Spring Security를 활성화
public class SecurityConfig {
@Autowired
private JwtRequestFilter jwtRequestFilter;
// HTTP 보안 설정을 구성하는 메서드
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorizeRequests -> // HTTP 요청에 대한 보안 규칙을 정의
authorizeRequests
.requestMatchers("/user/signup", "/user/login").permitAll() // 회원가입, 로그인에 대한 접근 허용
.anyRequest().authenticated() // 그 외의 모든 요청은 인증을 요구
)
// .httpBasic(withDefaults()) // HTTP Basic 인증 활성화 -> 주로 간단한 테스트를 위해 사용됨. JWT토큰 인증을 사용하면 없어도 됨
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class) // JWT 필터 추가
.cors(cors -> cors.disable()) // cors 비활성화: 외부에서 해당 애플리케이션 리소스에 접근할 수 있게 해주는 보안 기능
.csrf(csrf -> csrf.disable());
return http.build(); // HTTP 보안 설정을 빌드하여 반환
}
// Spring Security의 인증 관리자. 사용자의 인증을 관리
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder authenticationManagerBuilder =
http.getSharedObject(AuthenticationManagerBuilder.class);
authenticationManagerBuilder
.userDetailsService(userDetailsService) // 사용자 정보를 로드
.passwordEncoder(passwordEncoder()); // 비밀번호 검증
return authenticationManagerBuilder.build();
}
}
UserDetail 커스텀
- SecurityConfig.java
@Configuration // Spring 설정 클래스임
@EnableWebSecurity // Spring Security를 활성화
public class SecurityConfig {
@Autowired
private CustomUserDetailsService userDetailsService;
// HTTP 보안 설정을 구성하는 메서드
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorizeRequests -> // HTTP 요청에 대한 보안 규칙을 정의
authorizeRequests
.requestMatchers("/user/**").permitAll() // /user/** 경로에 대한 접근을 허용
.anyRequest().authenticated() // 그 외의 모든 요청은 인증을 요구
)
.csrf(csrf -> csrf.disable());
return http.build(); // HTTP 보안 설정을 빌드하여 반환
}
// 비밀번호 인코더를 정의
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // BCryptPasswordEncoder를 반환. 이는 비밀번호를 암호화하는 데 사용됨
}
// Spring Security의 인증 관리자. 사용자의 인증을 관리
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder authenticationManagerBuilder =
http.getSharedObject(AuthenticationManagerBuilder.class);
authenticationManagerBuilder
.userDetailsService(userDetailsService) // 사용자 정보를 로드
.passwordEncoder(passwordEncoder()); // 비밀번호 검증
return authenticationManagerBuilder.build();
}
}
- CustomUserDetails.java
저는 Spring Security의 기본 UserDetails 구현체 대신 아래 코드와 같이 사용자 정의 클래스를 사용했습니다.
제가 만든 user 테이블에는 id와 email 필드가 포함되어 있습니다. 이러한 필드를 UserDetails 인터페이스를 구현한 CustomUserDetails 클래스에 추가하여 데이터베이스 구조와 일치시켰습니다.
Spring Security의 기본 UserDetails 구현체를 사용하지 않고 사용자 정의 클래스를 구현함으로써, 데이터베이스 구조와 일치하는 사용자 정보를 제공하고, 애플리케이션의 요구 사항에 맞는 맞춤형 인증 및 권한 부여 로직을 구현할 수 있습니다.
@Getter
@ToString
public class CustomUserDetails implements UserDetails { // UserDetails 인터페이스를 구현하여 Spring Security가 사용자 인증 과정에서 필요한 정보를 제공
private final String id;
private final String username;
private final String email;
private final String password;
private final Collection<? extends GrantedAuthority> authorities; // 사용자의 권한 목록
@Getter
private final boolean enabled; // 계정의 활성화 여부
public CustomUserDetails(String id, String username, String email, String password, Collection<? extends GrantedAuthority> authorities, boolean enabled) {
this.id = id;
this.username = username;
this.email = email;
this.password = password;
this.authorities = authorities;
this.enabled = enabled;
}
// 계정의 만료 여부
@Override
public boolean isAccountNonExpired() {
return UserDetails.super.isAccountNonExpired();
}
// 계정의 잠금 여부
@Override
public boolean isAccountNonLocked() {
return UserDetails.super.isAccountNonLocked();
}
// 자격 증명의 만료 여부
@Override
public boolean isCredentialsNonExpired() {
return UserDetails.super.isCredentialsNonExpired();
}
}
- CustomUserDetailsService.java
기본 UserDetailsService 구현체는 일반적인 사용자 정보만 처리할 수 있습니다. 하지만 우리 애플리케이션은 사용자 ID, 이메일 등 추가적인 정보를 필요로 하기 때문에 커스텀 구현을 통해 추가적인 사용자 정보를 처리했습니다.
무엇보다 기본 UserDetailsService는 loadUserByUsername 메서드를 사용하여 사용자 이름(username)으로 사용자를 검색합니다.
저는 email 값으로 사용자를 검색하고 싶었기에 userMapper의 sql쿼리에 따라 사용자를 조회하도록 커스텀 하였습니다.
// 사용자 정보를 데이터베이스에서 로드하고, 이를 CustomUserDetails로 변환하여 반환하는 역할
@Service
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
@Autowired // UserMapper 빈을 자동으로 주입
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
// 데이터베이스에서 이메일을 기반으로 사용자 정보를 조회
UserDto.UserBase user = userMapper.findByEmail(UserDto.UserSearchByEmailCondition.builder()
.email(email)
.build());
if(user == null){
// 사용자가 없을 경우 예외처리
throw new UsernameNotFoundException(email + " 이메일로 가입된 사용자가 없습니다");
}
// 사용자의 권한 목록을 생성
List<GrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority("user"));
// CustomUserDetails 객체를 생성하여 반환
CustomUserDetails customUserDetails = new CustomUserDetails(user.getId(), user.getUsername(), user.getEmail(), user.getPassword(), authorities, user.isEnabled());
return customUserDetails;
}
}
JwtRequestFilter - HTTP 헤더에서 토큰 추출,검증
JwtRequestFilter 클래스는 OncePerRequestFilter를 상속하여 JWT 기반 인증을 처리하는 커스텀 필터입니다.
추상 클래스 OncePerRequestFilter를 상속하여 JwtRequestFilter에 필터링 로직을 구현합니다.
모든 요청에 대해 한 번만 실행되는 필터를 구현할 때 사용하는 기본 클래스로, Spring Security 필터 체인 내에서 요청이 한 번만 처리되도록 보장하는 역할을 합니다.
주요 메서드: doFilter, doFilterInternal
doFilterInternal 메소드를 오버라이드하여 실제 필터링 로직을 구현하며, doFilter 메소드에서 이를 호출합니다.
HTTP 요청을 받을 때마다 doFilterInternal 메소드가 실행되어 요청에 대한 Jwt 필터링 로직이 적용됩니다.
JwtRequestFilter의 역할
- HTTP 요청 가로채기: 모든 요청을 가로채서 처리합니다.
- JWT 토큰 추출: HTTP 요청의 Authorization 헤더에서 JWT 토큰을 추출합니다.
- JWT 토큰 검증: 추출한 JWT 토큰을 검증하여 유효성을 확인합니다.
- SecurityContext 설정: JWT가 유효한 경우, 사용자 정보를 로드하고, Spring Security의 SecurityContext에 인증 정보를 설정합니다.
- JwtRequestFilter.java
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
@Autowired
private CustomUserDetailsService customUserDetailsService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
// 필터링 로직을 정의
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// Authorization 헤더에서 JWT 토큰을 가져옴
final String authorizationHeader = request.getHeader("Authorization");
String email = null;
String jwt = null;
// Authorization 헤더가 존재하고 'Bearer '로 시작하는지 확인
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
// 'Bearer ' 다음의 JWT 토큰 부분만 추출
jwt = authorizationHeader.substring(7);
// JWT 토큰에서 이메일 추출
email = jwtTokenUtil.extractEmail(jwt);
}
// 이메일이 존재하고, 현재 SecurityContext에 인증 정보가 없는 경우
if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 이메일을 이용해 사용자 정보를 로드
CustomUserDetails userDetails = (CustomUserDetails) this.customUserDetailsService.loadUserByUsername(email);
// JWT 토큰이 유효한지 검증
if (jwtTokenUtil.validateToken(jwt, userDetails)) {
// 유요한 경우, 인증 객체를 생성하여 SecurityContext에 설정
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
// 다음 필터(UsernamePasswordAuthenticationFilter)로 요청을 전달하여 로그인 요청 처리
chain.doFilter(request, response);
}
}
JwtTokenUtil - Jwt 생성,추출,검증
JwtTokenUtil 유틸리티 클래스는 Jwt와 관련된 모든 세부 작업을 처리합니다.
JwtRequestFilter에서 Jwt의 유효성을 검증하고 사용자 정보를 추출하기 위해 해당 유틸리티를 사용하는 것입니다.
JwtTokenUtil의 역할
- JWT 생성: 사용자 정보를 기반으로 JWT를 생성합니다.
- JWT 클레임 추출: JWT 토큰에서 이메일, 만료 시간 등 클레임을 추출합니다.
- JWT 유효성 검증: JWT의 유효성을 검증합니다.
클레임(Claim)은 JWT(JSON Web Token)의 페이로드 부분에 저장되는 정보입니다.
- 신분증: JWT (JSON Web Token)
- 신분증의 정보: 클레임(Claims)
- 이름: 사용자 이름 (예: 김자바)
- 생년월일: 사용자 생년월일 (예: 1990-01-01)
- 만료일: 신분증의 유효기간 (예: 2025-01-01)
- JwtTokenUtil.java
@Component
public class JwtTokenUtil {
// secret.key 파일의 고정된 비밀 키 사용
@Value("${secret.key.path}")
private Resource secretKeyResource;
private String secretKey;
@PostConstruct // secret key 초기화 안전성을 위해 모든 의존성이 주입된 후 호출되도록함
public void init() {
try {
this.secretKey = new String(Files.readAllBytes(Paths.get(secretKeyResource.getURI())));
} catch (IOException e) {
throw new RuntimeException("secret.key 를 읽어올 수 없습니다", e);
}
}
// JWT 토큰에서 이메일을 추출
public String extractEmail(String token) {
return extractClaim(token, Claims::getSubject); // subject를 이메일로 사용
}
// JWT 토큰에서 만료 시간을 추출
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
// JWT 토큰에서 특정 클레임을 추출
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
// JWT 토큰에서 모든 클레임을 추출
private Claims extractAllClaims(String token) {
return Jwts.parserBuilder().setSigningKey(secretKey.getBytes()).build().parseClaimsJws(token).getBody();
}
// JWT 토큰이 만료되었는지 확인
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
// 사용자 정보를 기반으로 JWT 토큰 생성
public String generateToken(CustomUserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getEmail());
}
// 클레임과 주제를 기반으로 JWT 토큰 생성
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() + 1000 * 60 * 60 * 24)) // 24시간 동안 유효
.signWith(SignatureAlgorithm.HS256, secretKey.getBytes()).compact();
}
// JWT 토큰의 유효성 검증
public Boolean validateToken(String token, CustomUserDetails userDetails) {
final String email = extractEmail(token);
return (email.equals(userDetails.getEmail()) && !isTokenExpired(token));
}
}
보안과 유지보수를 위해 secretKey는 하드코딩 하지 않고 환경변수나 설정 파일을 사용해야 합니다.
저는 resources/secret.key 파일을 만들어 주었고 해당 파일에 secretKey를 작성해주었습니다.
실제 배포 시엔 AWS나 Azure 같은 외부 서비스를 사용해서 비밀키에 접근해주도록 설계하면 더욱 안전합니다.
- application.yml
애플리케이션에서 사용할 비밀 키 파일의 경로를 작성해줍니다.
secret:
key:
path: classpath:secret.key
로그인 api 구현
- UserController.java
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private AuthenticationManager authenticationManager;
private final String EP_LOGIN = "/login";
@Autowired
private JwtTokenUtil jwtTokenUtil;
@GetMapping(EP_LOGIN)
public String login(@RequestBody UserRequest.LoginRequest body) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(body.getEmail(), body.getPassword());
Authentication authentication = authenticationManager.authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
String jwtToken = jwtTokenUtil.generateToken(userDetails);
return "Bearer " + jwtToken;
}
}
- UserDto.java
@NoArgsConstructor
@Data
public class UserDto {
@Data
public static class UserBase {
private String id;
private String username;
@JsonIgnore
private String password;
private String email;
private boolean enabled;
private String role;
private String createdAt;
private String updatedAt;
}
@Data
@Builder
public static class UserSearchByEmailCondition {
private String email;
}
}
- UserMapper.java
@Mapper
public interface UserMapper {
UserDto.UserBase findByEmail(UserDto.UserSearchByEmailCondition param);
}
- UserMapper.xml
<select id="findByEmail"
parameterType="com.tistory.project_api.dto.UserDto$UserSearchByEmailCondition"
resultType="com.tistory.project_api.dto.UserDto$UserBase">
SELECT id, username, password, email, enabled, role
, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS createdAt
, DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updatedAt
FROM user
WHERE email = #{email}
</select>
- UserService.java → 변경사항X
CustomUserDetailsService.java 에서 loadUserByUsername 메서드 오버라이딩을 통해 이메일을 기반으로 사용자 정보를 조회하는 로직을 구현해주었습니다.
GitHub - a-taeyeon/springboot-tistory-example
Contribute to a-taeyeon/springboot-tistory-example development by creating an account on GitHub.
github.com
'Web' 카테고리의 다른 글
[springboot] 10가지 응답처리 방식 (동기, 비동기 및 스트리밍 응답) (0) | 2024.06.11 |
---|---|
[springboot] Spring Security 6.x로 소셜 로그인 구현하기 (oauth2) (1) | 2024.06.08 |
[springboot] Spring Security 6.x로 안전한 회원가입 구현하기 (security 연결, 패스워드 인코딩) (0) | 2024.06.04 |
[springboot] 로그 설정1 (slf4j, logback) - 가장 쉽게 mybatis 쿼리 출력하기 (1) | 2024.06.03 |
Springboot Security는 어떤 역할을 할까? (0) | 2024.06.01 |