[Spring] Spring Security & JWT

시내·2024년 4월 22일

Spring

목록 보기
9/10

JWT 알아보기

해당 게시글에서는 Access Token만 사용중 💻

Application.yml

#spring.application.name=bulletin_board
spring:
  security:
    user:
      name: user
      password: password
  datasource:
    driver-class-name: org.mariadb.jdbc.Driver
    url: ...
    username: ...
    password: ...
  jpa:
    database: mysql
    database-platform: org.hibernate.dialect.MariaDBDialect
    generate-ddl: true
    hibernate:
      ddl-auto: create
    show-sql: true
jwt:
  expiration: 30
  secretKey: mysecret

SecurityConfig

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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;

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    // Customized된 JWT필터 주입
    private final JwtAuthFilter jwtAuthFilter;

    public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
        this.jwtAuthFilter = jwtAuthFilter;
    }

    // 비밀번호 암호화
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .csrf().disable() //csrf 보안 설정 비활성화
                //csrf 사용하려면 frontend단에서 csrf token값을 전달해줘야함
                .cors().and() //CORS 활성화
                .httpBasic().disable()
                .authorizeRequests() //보호된 URI에 접근할 수 있는 권한을 설정
                .antMatchers("/member/create",
                        "/doLogin")
                .permitAll() // 위 end point들은 인증이 필요 없음
                .anyRequest().authenticated() // 나머지는 인증이 필요함
                .and()
                .sessionManagement() // 세션 관리 기능이 작동
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                // 더 이상 session cookie 방식의 인증 방식으로 인증 처리 하지 않음
                .and()
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
                // 지정된 필터 앞에 커스텀 필터를 추가
                // 인증을 처리하는 기본필터 UsernamePasswordAuthenticationFilter 대신
                // 별도의 인증 로직을 가진 필터를 생성&사용하고 싶을 때 사용
                .build();
    }
}

🦑 @EnableWebSecurity

  • 웹 보안 설정 구성, Spring Security 설정을 customizing 하기 위함
  • @Configuration 어노테이션을 포함하고 있음
  • 자동으로 SecurityFilterChain 생성
  • WebSecurityConfigurerAdapter 를 상속하는 방식은 deprecated 됨
  • Spring Security Configuration 클래스에서 선언됨

🦑 @EnableMethodSecurity

  • @EnableGlobalMethodSecurity 를 사용하는 것은 deprecated 됨
  • @EnableMethodSecurity(securedEnabled = true, prePostEnabled = true) 에서
    - securedEnabled : @Secured 사용 여부
    - prePostEnabled : @PreAuthorize / @PostAuthorize사용 여부
🐙 @PreAuthorize
  • 메서드가 실행되기 전에 인증을 거침
🐙 @PostAuthorize
  • 메서드가 실행되고 나서 응답을 보내기 전에 인증을 거침

JwtAuthFilter

Client가 요청을 할 때마다 인가 처리 담당

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
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.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.security.sasl.AuthenticationException;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

@Component
public class JwtAuthFilter extends GenericFilter {
    @Value("${jwt.secretKey}")
    private String secretKey;

    public static final String HEADER_KEY = "Authorization";
    public static final String PREFIX = "Bearer ";

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        // 1) 요청 header에서 Authorization 값을 가져옴
        String bearerToken = ((HttpServletRequest) request).getHeader(HEADER_KEY);
        try {
            if (bearerToken != null) {
                if (!bearerToken.substring(0, 7).equals(PREFIX)) {
                // Bearer 형식이 아니면 예외가 발생한다.
                    throw new AuthenticationException("토큰이 일치하지 않아요.");
                }
                // "Bearer " 이후의 실제 토큰 부분만 추출
                String token = bearerToken.substring(7); 
                //토큰 검증 및 claims 추출
                Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
                
                // Authentication 객체 생성 위해서는 UserDetails 생성해야함
                // [아래는 UserDetails 생성하는 코드]
                // Spring Security에서의 권한들과 역할은 기본적으로 GrantedAuthority에 저장
                // GrantedAuthority는 권한이나 역할의 이름을 반환하는 메소드를 제공
                List<GrantedAuthority> authorities = new ArrayList<>();
                
                // SimpleGrantedAuthority 클래스는 GrantedAuthority 인터페이스의 기본으로 구현되는 객체
                // 역할을 설정
                authorities.add(new SimpleGrantedAuthority("ROLE_" + claims.get("role")));
                UserDetails userDetails = new User(claims.getSubject(), "", authorities);

                // [아래는 Authentication 생성하는 코드]
                Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails,
                        "", userDetails.getAuthorities());
                        
                // 인증 완료
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            //filterchain에서 그 다음 filter로 넘어가도록 하는 메서드
            chain.doFilter(request, response);

        } catch (Exception e) {
            HttpServletResponse httpServletResponse = (HttpServletResponse) response;
            httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
            httpServletResponse.setContentType("application/json"); // 인증 실패 시, 401 UNAUTHORIZED 응답을 반환
//            httpServletResponse.getWriter().write(ErrorResponseDto.makeMessage(HttpStatus.UNAUTHORIZED,
//                    e.getMessage()).toString());
        }
    }
}

🦑 Claim
JWT의 본문 (payload)에 포함된 데이터

  • JWT 발급 시,이메일과 역할 데이터를 담음
  • JWT 검증 후, Claims에서 이 데이터를 다시 꺼낼 수 있음

🦑 "ROLE_" + claims.get("role") 형식?
Spring Security에서는 권한(Authority)을 부여할 때 역할 이름이 "ROLE_"로 시작해야 함

🦑 getSubject()?

UserDetails userDetails = new User(claims.getSubject(), "", authorities);
  • JWT에서 sub (subject) 필드에 저장된 값을 가져옴
  • JWT 생성 시, 이메일을 sub로 저장했었음
    Claims claims = Jwts.claims().setSubject(email);

-> 그래서 getSubject() 를 하면 사용자의 이메일을 가져오게 됨!

🦑 GrantedAuthority & UserDetails & Authentication?

GrantedAuthority
사용자의 role 정보를 담는 객체

List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_" + claims.get("role")));

-> 위에서는 "ROLE_USER" 역할을 가진다는 걸 저장

UserDetails
Spring Security에서 사용자의 정보를 담는 객체

UserDetails userDetails = new User(claims.getSubject(), "", authorities);

1) claims.getSubject() : 이메일 (사용자 이름 / 식별자)
2) "" : 비밀번호 (여기서는 JWT 인증이라 필요 없음)
3) authorities: 권한 리스트

Authentication
Spring Security에서 인증된 사용자의 정보를 저장하는 객체

Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());

-> UsernamePasswordAuthenticationToken을 사용하면 JWT 기반 인증을 Security Context에 저장 가능

JwtTokenProvider

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;

import java.util.Date;

@Component
public class JwtTokenProvider {
    @Value("${jwt.secretKey}")
    private String secretKey;

    @Value("${jwt.expiration}")
    private int expiration;

    public String createToken(String email, String role) {
        Claims claims = Jwts.claims().setSubject(email); //이메일을 저장
        claims.put("role", role); // 역할(role) 추가
        Date now = new Date();
        JwtBuilder jwtBuilder = Jwts.builder();
        jwtBuilder.setClaims(claims);
        jwtBuilder.setIssuedAt(now);
        // 만료시간 설정
        jwtBuilder.setExpiration(new Date(now.getTime() + expiration * 60 * 1000L)); 
        // 서명
        jwtBuilder.signWith(SignatureAlgorithm.HS256, secretKey);
        return jwtBuilder.compact(); //토큰 생성
    }
}

1) 사용자 emailroleClaims에 저장한다.
2) 발급 시간(issuedAt)과 만료 시간(expiration)을 설정한다.
3) HS256 알고리즘을 사용해 서명(Sign)한다.
4) 최종적으로 JWT 토큰을 문자열 형태로 반환한다.

LoginReqDto

package com.ecommerce.online_shopping.member;

import lombok.Data;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;

@Data
public class LoginReqDto {
    @NotEmpty(message = "이메일을 입력하세요.")
    @Email(message = "옳지 않은 형식의 이메일이에요.")
    private String email;

    @NotEmpty(message = "비밀번호를 입력하세요.")
    @Size(min = 4, message = "비밀번호는 최소 4글자 이상이어야해요.")
    private String password;
}

MemberController

 @PostMapping("/doLogin")
    public ResponseEntity<CommonResponse> login(@Valid @RequestBody LoginReqDto loginReqDto) {
        Member member = memberService.login(loginReqDto); // 이메일과 비밀번호 확인
        String jwtToken = jwtTokenProvider.createToken(member.getEmail(), member.getRole().toString()); //JWT 생성
        Map<String, Object> member_info = new HashMap<>();
        member_info.put("id", member.getId());
        member_info.put("token", jwtToken);
        return new ResponseEntity<>(new CommonResponse(HttpStatus.OK, "로그인에 성공했어요!", member_info), HttpStatus.OK);
    }

1) loginReqDto 에서 이메일과 비밀번호를 받는다.
2) memberService.login() 을 호출해서 이메일과 비밀번호를 확인한다.
3) 로그인 성공시, jwtTokenProvider.createToken() 으로 JWT를 발급한다.
4) 사용자 정보 (idtoken)를 CommonResponse에 담아서 반환한다.

MemberService

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class MemberService {
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    public MemberService(MemberRepository memberRepository, PasswordEncoder passwordEncoder) {
        this.memberRepository = memberRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public Member login(LoginReqDto loginReqDto) throws IllegalArgumentException {
        Member member = memberRepository.findByEmail(loginReqDto.getEmail()).orElseThrow(() -> new IllegalArgumentException("해당 이메일을 가지고 있는 회원은 존재하지 않아요."));
        if (!passwordEncoder.matches(loginReqDto.getPassword(), member.getPassword())) {
            throw new IllegalArgumentException("비밀번호가 일치하지 않아요.");
        }
        return member;
    }
}

1) email을 기준으로 회원을 찾는다.
2) 회원이 없으면 예외가 발생한다.
3) passwordEncoder.matches() 로 비밀번호가 일치하는지 확인한다.
4) 비밀번호까지 맞으면 Member 객체를 반환한다.

Postman 예시

🦑 1. Create Member (localhost:8080/member/create)

🦑 2. Login (localhost:8080/user/login)

1) Body JSON Data (@RequestBody)

2) Tests 설정

  • var jsonData = pm.response.json();
    - JSON Response Data를 JS 객체로 변환
  • pm.environment.set("my_token", jsonData.result.token);
    -pm.environment.set() 는 Postman에서의 환경변수 설정 / 업데이트하는 함수
    -my_token이라는 환경변수에 JSON Response의 result 부분의 token값을 넣어줌

3) Token 구성

4) 추후 API Test

✅ Postman의 Auth에서 Type을 Bearer Token 지정 후, Postman의 환경변수인 {{my_token}}을 설정해줘야함


✅ Postman의 기본 저장소는 'Globals'이기 때문에 반드시 Postman의 environment는 본인이 설정한 environment임을 확인해야함!


출처 : https://velog.io/@shon5544/Spring-Security-4.-%EA%B6%8C%ED%95%9C-%EC%B2%98%EB%A6%AC

https://jjangadadcodingdiary.tistory.com/entry/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0%EC%97%90%EC%84%9C-EnableWebSecurity-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98%EC%9D%98-%ED%99%9C%EC%9A%A9-%EB%B0%A9%EB%B2%95%EA%B3%BC-%EA%B8%B0%EB%8A%A5

profile
contact 📨 ksw08215@gmail.com

0개의 댓글