내코드)
https://github.com/jamwomsoo/JWT-Spring-Security-login-
이분꺼 보면서 테스트 프로젝트로 따라해보고 본 코드에 따로 적용함 - 내용도 거의 다 비슷
내가 JWT + Spring Security를 사용한 이유:
Spring Security란?
1. 의존성(build.gradle)
implementation 'org.springframework.boot:spring-boot-starter-security'
2.도메인
@Entity
@Getter
@Builder
@NoArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String nickname;
@Enumerated(EnumType.STRING)
private Authority authority;
public void setNickname(String nickname){
this.nickname = nickname;
}
public void setPassword(String password){this.password = password;}
@Builder
public Member(Long id, String email, String password, String nickname, Authority authority){
this.id = id;
this.email = email;
this.password = password;
this.nickname = nickname;
this.authority = authority;
}
}
/**
* Spring Security User Role을 위한 Enum 타입 Role
*/
public enum Authority {
ROLE_USER, ROLE_ADMIN
}
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
boolean existsByEmail(String email);
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
/**
* 헤더에 있는 token 값을 토대로 Member의 data를 건내주는 메소드
* @return
*/
public MemberResponseDto getMyInfoBySecurity(){
return memberRepository.findById(SecurityUtil.getCurrentMemberId())
.map(MemberResponseDto::of)
.orElseThrow(() -> new RuntimeException("로그인 유저 정보가 없습니다."));
}
/**
* 닉네임 변경 메소드
* @param email
* @param nickname
* @return
*/
@Transactional
public MemberResponseDto changeMemberNickname(String email, String nickname){
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("로그인 유정 정보가 없습니다."));
member.setNickname(nickname);
return MemberResponseDto.of(memberRepository.save(member));
}
/**
* 패스워드 변경 메소드
* token값을 토대로 찾은 Member를 통해서 예전 패스워드와 DB의 데이터와 비교
* @param email
* @param exPassword
* @param newPassword
* @return
*/
@Transactional
public MemberResponseDto changeMemberPassword(String email, String exPassword, String newPassword){
Member member = memberRepository.findById(SecurityUtil.getCurrentMemberId())
.orElseThrow(() -> new RuntimeException("로그인 유저 정보가 없습니다."));
if(!passwordEncoder.matches(exPassword, member.getPassword())){
throw new RuntimeException("비밀번호가 맞지 않습니다.");
}
member.setPassword(passwordEncoder.encode((newPassword)));
return MemberResponseDto.of(memberRepository.save(member));
}
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {
private final MemberService memberService;
@GetMapping("/me")
public ResponseEntity<MemberResponseDto> getMyMemberInfo(){
MemberResponseDto myInfoBySecurity = memberService.getMyInfoBySecurity();
return ResponseEntity.ok(myInfoBySecurity);
}
@PostMapping("/nickname")
public ResponseEntity<MemberResponseDto> setMemberNickname(@RequestBody MemberRequestDto requestDto){
return ResponseEntity.ok(memberService.changeMemberNickname(requestDto.getEmail(),requestDto.getNickname() ));
}
@PostMapping("/password")
public ResponseEntity<MemberResponseDto> setMemberPassword(@RequestBody ChangePasswordRequestDto requestDto){
return ResponseEntity.ok(memberService.changeMemberPassword(requestDto.getEmail(),requestDto.getExPassword(), requestDto.getNewPassword()));
}
}
spring:
datasource:
url: jdbc:mariadb://localhost:3306/testdb
driver-class-name: org.mariadb.jdbc.Driver
username: root
password: 1234
jpa:
hibernate:
ddl-auto: create-drop
properties:
hibernate:
format_sql: true
show_sql: true
logging:
level:
com.tutorial: debug
jwt:
secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWppd29vbi1zcHJpbmctYm9vdC1zZWN1cml0eS1qd3QtdHV0b3JpYWwK
3. JWT설정
- TokenProvider: 유저 정보로 JWT토큰을 만들거나 토큰을 바탕으로 유저 정보를 가져옴
: 토큰을 생성하고 검증하는 클래스
: 해당 컴포넌트는 필터 클래스에서 사전 검증을 거칩니다.
-> AuthenticationManager에 등록되어 인증을 실질적으로 처리함
- JWTFilter : Spring Request 앞단에 붙일 Custom Filter
@Component
@Slf4j
public class TokenProvider {
private static final String AUTHORITIES_KEY = "auth"; // 토큰 생성과 검증을 위해 쓰임
private static final String BEARER_TYPE = "bearer"; // 토큰 생성과 검증을 위해 쓰임
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 7일
private final Key key;
// annotation으로 yml에 있는 secret key를 가져온 다음 Decode함
public TokenProvider(@Value("${jwt.secret}") String secretKey){
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
/**
* Describe : 토큰을 만드는 메소드
* @param authentication
* @return TokenDto에 생성한 Token 정보를 넣어서 return
*
*/
public TokenDto generateTokenDto(Authentication authentication){
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date tokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
// access token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.setExpiration(tokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
//refresh token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
.refreshToken(refreshToken)
.build();
}
/**
* Describe : 받은 토큰의 인증을 꺼내는 메소드
* @param accessToken
* @return
*
*/
public Authentication getAuthentication(String accessToken){
Claims claims = parseClaims(accessToken);
if(claims.get(AUTHORITIES_KEY) == null){
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// claims 형태의 토큰을 알맞게 정렬한 이후 SimpleGrantedAuthority 형태의 새로운 list 생성 - 인가가 들어있음
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
/**
* Describe : 토큰을 검증하기 위한 메소드
* @param token
* @return
*/
public boolean validateToken(String token){
try{
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
}catch(io.jsonwebtoken.security.SecurityException | MalformedJwtException e){
log.info("잘못된 JWT 서명입니다.");
}catch(ExpiredJwtException e){
log.info("만료된 JWT 토큰입니다.");
}catch(UnsupportedJwtException e){
log.debug("지원되지 않는 JWT 토큰입니다.");
}catch (IllegalArgumentException e){
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
/**
* Describe : 토큰을 Claim 형태로 만드는 메소드, 이를 통해 위에서 권한 정보가 있는지 없는지 체크가 가능
*
* @param accessToken
* @return
* String 형태의 토큰을 claims 형태로 생성
*/
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e){
return e.getClaims();
}
}
}
추가 설명
getAuthentication
ValidateToken
- Jwts 모듈이 알아서 Exception을 던진다
/**
* Class : JwtFilter
* <p>
* Describe : 해당 클래스는 JwtTokenProvider가 검증을 끝낸 검증을 끝낸 jwt로부터 유저 정보를 조회해 와서 UserPasswordAuthenticationFilter로 전달합니다.
*/
equiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer";
private final TokenProvider tokenProvider;
/**
* Request Header에서 토큰 정보를 꺼내오는 메소드
* @param request
* @return
*/
private String resolveToken(HttpServletRequest request){
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)){
return bearerToken.substring(7);
}
return null;
}
/**
* 필터링을 실행하는 메소드
* @param request
* @param response
* @param filterChain
* @throws ServletException
* @throws IOException
* resolveToken을 통해 토큰 정보를 꺼내온 다음, validateToken으로 토큰이 유효한지 검사를 해서 , 유효하다면 Authentication을 가져와 SecurityContext에 저장
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 1. Request Header에서 토큰을 꺼냄
String jwt = resolveToken(request);
// 2. validateTokendbgytjd rjatk
// 3 정상 토큰일 때 Authentication을 꺼내와서 SecurityContext에 저장
if(StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)){
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
4. Security 설정
/**
* SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> 인터페이스 구현체
* 직접만든 TokenProvider와 JwtFilter를 SecurityConfig에 적용할 때 사용
*/
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final TokenProvider tokenProvider;
/**
* TokenProvider를 주입받아서 JwtFilter를 통해 SecurityConfig안에 필터를 등록하게 되고, 스프링 시큐리티 전반적인 필터에 적용됨
* @param http
*/
@Override
public void configure(HttpSecurity http){
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
유저 정보 없이 접근하면 SC_UNAUTHORIZED (401) 응답을 내림
/**
* 유효치 않은 접근을 할 때 response에 error를 만들어주는 컴포넌트
*/
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 필요한 권한이 없이 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
유저 정보는 있으나 자원에 접근할 수 있는 권한이 없는 경우 SC_FORBIDDEN (403)응답을 내려줌
/**
* 유효치 않은 접근을 할 때 response에 error를 만들어주는 컴포넌트
*/
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
@Component
public class WebSecurityConfig {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
/**
* request로부터 받은 비밀번호를 암호화하기 위한 메소드
* @return
*/
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http
.httpBasic().disable() //https만 사용
.csrf().disable() // csrf 방지 막음
// Spring Security는 기본적으로 세션을 사용 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)//Rest api를 통해 세션 없이 토큰을 주고 받기 때문에 Stateless 설정
.and()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.authorizeRequests()
.antMatchers("/auth/**").permitAll() // 여기가 로그인 페이지-로그인 페이지만 허용
.anyRequest().authenticated() // 나머지 URL들은 모두 인증된 사용저들에게만 허용하게 함
.and()
.apply(new JwtSecurityConfig(tokenProvider)); //JwtSecurityConfig 클래스를 통해 tokenprovider를 적용시킴
return http.build();
}
}
/**
* 유저 정보가 저장되는 시점에 다루는 클래스
* Request가 들어오면 JwtFilter의 doFilter에서 저장되는데 거기에 있는 인증정보를 꺼내서, 그 안의 ID를 반환
*/
public class SecurityUtil {
private SecurityUtil(){}
public static Long getCurrentMemberId(){
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(authentication == null || authentication.getName() == null){
throw new RuntimeException("Security Context에 인증 정보가 없습니다.");
}
return Long.parseLong(authentication.getName());
}
}
Refresh Token (+ Redis Repository와 함께 다음 단계에서 적용)
@Getter
@NoArgsConstructor
@Table(name = "refresh_token")
@Entity
public class RefreshToken {
@Id
@Column(name = "rt_key")
private String key;
@Column(name = "rt_value")
private String value;
@Builder
public RefreshToken(String key, String value) {
this.key = key;
this.value = value;
}
public RefreshToken updateValue(String token) {
this.value = token;
return this;
}
}
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByKey(String key);
}
5. 사용자 인증 과정
실제로 사용자 로그인 요청이 들어왔을 때 인증 처리 후에 JWT 토큰을 발급하는 과정을 서술함
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
//회원가입
@PostMapping("/signup")
public ResponseEntity<MemberResponseDto> singup(@RequestBody MemberRequestDto requestDto){
return ResponseEntity.ok(authService.singup(requestDto));
}
//로그인
@PostMapping("/login")
public ResponseEntity<TokenDto> login(@RequestBody MemberRequestDto requestDto){
return ResponseEntity.ok(authService.login(requestDto));
}
// 토큰 재발급
@PostMapping("/reissue")
public ResponseEntity<TokenDto> reIssue(@RequestBody TokenRequestDto tokenRequestDto){
return ResponseEntity.ok(authService.reIssue(tokenRequestDto));
}
}
TokenRequestDto [AccessToken, RefreshToken]으로 구성
@RequiredArgsConstructor
@Transactional
@Service
public class AuthService {
private final AuthenticationManagerBuilder managerBuilder;
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public MemberResponseDto singup(MemberRequestDto requestDto){
if(memberRepository.existsByEmail(requestDto.getEmail())){
throw new RuntimeException("이미 가입되어 있는 유저입니다.");
}
Member member = requestDto.toMember(passwordEncoder);
return MemberResponseDto.of(memberRepository.save(member));
}
@Transactional
public TokenDto login(MemberRequestDto requestDto){
// 1. Login ID/PW를 기반으로 AuthenticationToken 생성
UsernamePasswordAuthenticationToken authenticationToken = requestDto.toAuthentication();
// 2. 실제로 검증(사용자 비밀번호 체크)이 이루어지는 부분
// authenticate(authenticationToken) method 실행 시 CustomUserDetailService - loadUserByUsername method가 실행됨
Authentication authentication = managerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
// 4 .RefreshToken 저장
RefreshToken refreshToken = RefreshToken.builder()
.key(authentication.getName())
.value(tokenDto.getRefreshToken())
.build();
refreshTokenRepository.save(refreshToken);
// 5. 토큰 발급
return tokenDto;
}
@Transactional
public TokenDto reIssue(TokenRequestDto tokenRequestDto){
// 1. Refresh Token 검증
if(!tokenProvider.validateToken(tokenRequestDto.getRefreshToken())){
throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
}
// 2. Access Token에서 Member ID 가져오기
Authentication authentication = tokenProvider.getAuthentication(tokenRequestDto.getAccessToken());
// 3. 저장소에서 MemberId를 기반으로 Refresh Toeken값 가져오기
RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName())
.orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));
// 4. Refresh Token 일치하는 검사
if(!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())){
throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
}
// 5. 새로운 토큰 생성
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
// 6. 저장소 정보 업데이트
RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
refreshTokenRepository.save(newRefreshToken);
// 토큰 발급
return tokenDto;
}
}
회원가입(SignUp)
로그인(Login)
재발급(Reissue)
Refresh Token은 재사용하지 못하게 저장소(Redis)에서 값을 갱신
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByEmail(username)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException(username + "을 DB에서 찾을 수 없습니다."));
}
private UserDetails createUserDetails(Member member) {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthority().toString());
return new User(
String.valueOf(member.getId()),
member.getPassword(),
Collections.singleton(grantedAuthority)
);
}
}