기존 JPA 프로젝트를 그대로 복사 붙여넣기한 상태로 시작
<!-- 스프링 시큐리티 JWT -->
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
나와 있는 자료들 대부분 import는 생략해놔서 자동 import시 여러개 뜰때마다 찾아보고 하느라 애먹었다. 그래서 import까지 냅다 올려버림.
package com.my.jwt.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
/**
* 클라이언트에 토큰을 보내기 위한 DTO
* @author rimsong
* grantType: JWT에 대한 인증 타입. 여기서는 Bearer를 사용한다. 이후 HHTP 헤더에 prefix로 붙여주는 타입
*/
@Builder
@Data
@AllArgsConstructor
public class TokenDto {
private String grantType; // JWT에 대한 인증 타입. 여기서는 Bearer를 사용한다. 이후 HHTP 헤더에 prefix로 붙여주는 타입
private String accessToken;
private String refreshToken;
}
먼저, application.properties에
jwt.secret=아무글자나 256bit이상, 알파벳으로 32글자 이상 쳐줬다
package com.my.jwt.provider;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import com.my.jwt.dto.TokenDto;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
/**
* JwtTokenProvider는 토큰 생성, 토큰 복호화 및 정보 추출, 토큰 유효성 검증의 기능이 구현된 클래스.
* @author rimsong
* application.properties에 jwt.secret: 값을 넣어 설정 추가해준 뒤 사용합니다.
* jwt.secret는 토큰의 암호화, 복호화를 위한 secret key로서 이후 HS256알고리즘을 사용하기 위해, 256비트보다 커야합니다.
* 알파벳이 한 단어당 8bit니, 32글자 이상이면 됩니다! 너무 짧으면 에러가 뜹니다.
*/
@Slf4j
@Component
public class JwtTokenProvider {
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
public TokenDto generateToken(Authentication authentication) {
// 권한 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
// 숫자 86400000은 토큰의 유효기간으로 1일을 나타냅니다. 보통 토큰은 30분 정도로 생성하는데 테스트를 위해 1일로 설정했습니다.
// 1일: 24*60*60*1000 = 86400000
Date accessTokenExpiresIn = new Date(now + 86400000);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + 86400000))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return TokenDto.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
// 토큰 정보를 검증하는 메서드
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
package com.my.jwt.filter;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;
import com.my.jwt.provider.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
/**
* JwtAuthenticationFilter는 클라이언트 요청 시 JWT 인증을 하기위해 설치하는 커스텀 필터로, UsernamePasswordAuthenticationFilter 이전에 실행됨
* 이 말은 JwtAuthenticationFilter를 통과하면 UsernamePasswordAuthenticationFilter 이후의 필터는 통과한 것으로 본다는 뜻입니다. = username+password를 통한 인증을 JWT를 통해 수행한다는 것!
* @author rimsong
*
*/
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 1. Request Header 에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);
// 2. validateToken 으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
// Request Header 에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
}
return null;
}
}
package com.my.security;
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.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import com.my.jwt.filter.JwtAuthenticationFilter;
import com.my.jwt.provider.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
/**
* SecurityConfig는 Spring Security 설정을 위한 클래스.
* @author rimsong
*
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
/**
* httpBasic().disable().csrf().disable(): rest api이므로 basic auth 및 csrf 보안을 사용하지 않는다는 설정
* sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS): JWT를 사용하기 때문에 세션을 사용하지 않는다는 설정
* antMatchers().permitAll(): 해당 API에 대해서는 모든 요청을 허가한다는 설정
* antMatchers().hasRole("USER"): USER 권한이 있어야 요청할 수 있다는 설정
* anyRequest().authenticated(): 이 밖에 모든 요청에 대해서 인증을 필요로 한다는 설정
* addFilterBefore(new JwtAUthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class): JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행하겠다는 설정
* passwordEncoder: JWT를 사용하기 위해서는 기본적으로 password encoder가 필요한데, 여기서는 Bycrypt encoder를 사용
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/members/login").permitAll()
.antMatchers("/members/test").hasRole("USER")
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
package com.my.customer.entity;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
import javax.persistence.Column;
import javax.persistence.ElementCollection;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.Id;
import javax.persistence.Table;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name= "member")
public class Member implements UserDetails {
@Id
@Column(name = "member_id", updatable = false, unique = true, nullable = false)
private String memberId;
@Column(nullable = false)
private String password;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() {
return memberId;
}
@Override
public String getPassword() {
return password;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
package com.my.customer.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import com.my.customer.entity.Member;
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByMemberId(String username);
}
package com.my.customer.service;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.my.customer.repository.MemberRepository;
import com.my.jwt.dto.TokenDto;
import com.my.jwt.provider.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
/**
* 1. 로그인 요청으로 들어온 ID, PWD 기반으로 Authentication 객체 생성
* 2. authenticate() 메서드를 통해 요청된 Member에 대한 검증이 진행 => loadUserByUsername 메서드를 실행. 해당 메서드는 검증을 위한 유저 객체를 가져오는 부분으로써, 어떤 객체를 검증할 것인지에 대해 직접 구현
* 3. 검증이 정상적으로 통과되었다면 인증된 Authentication객체를 기반으로 JWT 토큰을 생성
*/
@Transactional
public TokenDto login(String memberId, String password) {
// 1. Login ID/PW 를 기반으로 Authentication 객체 생성
// 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(memberId, password);
// 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
// authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
TokenDto tokenDto = jwtTokenProvider.generateToken(authentication);
return tokenDto;
}
}
package com.my.security.user;
import org.springframework.security.core.userdetails.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.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import com.my.customer.entity.Member;
import com.my.customer.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByMemberId(username)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다."));
}
// 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 리턴
/**
* 여기서 PasswordEncoder를 통해 UserDetails 객체를 생성할 때 encoding을 해줌 => 왜냐하면 Spring Security는 사용자 검증을 위해 encoding된 password와 그렇지 않은 password를 비교하기 때문
* 실제로는 DB 자체에 encoding된 password 값을 갖고 있고 그냥 memer.getPassword()로 encoding된 password를 꺼내는 것이 좋지만, 예제에서는 편의를 위해 검증 객체를 생성할 때 encoding을 해줌.
*/
private UserDetails createUserDetails(Member member) {
return User.builder()
.username(member.getUsername())
.password(passwordEncoder.encode(member.getPassword()))
.roles(member.getRoles().toArray(new String[0]))
.build();
}
}
package com.my.customer.control;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.my.customer.dto.MemberLoginRequestDto;
import com.my.customer.service.MemberService;
import com.my.jwt.dto.TokenDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
@PostMapping("/login")
public TokenDto login(@RequestBody MemberLoginRequestDto memberLoginRequestDto) {
String memberId = memberLoginRequestDto.getMemberId();
String password = memberLoginRequestDto.getPassword();
TokenDto tokenDto = memberService.login(memberId, password);
return tokenDto;
}
@PostMapping("/test")
public String test() {
return "sucess";
}
}
여기서 나는 테스트를 위해 member 테이블과 member_roles 테이블을 만들어서
미리 행을 하나씩 Insert 해놓고 사용했다.
commmit; 을 실행한 후 테스트 하는걸 잊지말자.
create table member(
member_id varchar2(20) primary key,
password varchar2(20) not null
);
create table member_roles(
member_member_id varchar2(20) primary key,
roles varchar2(20) not null
);
ALTER table member_roles ADD CONSTRAINT fk_meber_role foreign key(member_member_id) references member (member_id);
insert into member values ('member_A', '1234');
insert into member_roles values ('member_A', 'USER');
commit;
package com.my.customer.dto;
import lombok.Data;
@Data
public class MemberLoginRequestDto {
private String memberId;
private String password;
}
package com.my.customer.control;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.my.customer.dto.MemberLoginRequestDto;
import com.my.customer.service.MemberService;
import com.my.jwt.dto.TokenDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
@PostMapping("/login")
public TokenDto login(@RequestBody MemberLoginRequestDto memberLoginRequestDto) {
String memberId = memberLoginRequestDto.getMemberId();
String password = memberLoginRequestDto.getPassword();
TokenDto tokenDto = memberService.login(memberId, password);
return tokenDto;
}
}
@PostMapping("/test")
public String test() {
return "sucess";
}
package com.my.security.util;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* SecurityUtil은 API 호출 시, Member의 정보가 헤더에 담겨져 올텐데, 어떤 Member가 API를 요청했는지 조회하는 코드가 담겨있음
* SecurityUtil.getCurrentMemberId() 코드를 사용하면 편리하게 현재 memberId를 조회할 수 있습니다.
* @author rimsong
*/
public class SecurityUtil {
public static String getCurrentMemberId() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || authentication.getName() == null) {
throw new RuntimeException("No authentication information.");
}
return authentication.getName();
}
}