ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 인턴 Day14 (Spring Boot + JWT + Security 정리2)
    인턴일지 2022. 5. 3. 13:41

    1. JWT

     JWT(Json Web Token)은 위와 같은 일련의 과정 속에서 나타난 하나의 인터넷 표준 인증 방식입니다. 말 그대로 인증에 필요한 정보들을 Token에 담아 암호화시켜 사용하는 토큰인 것이죠.

     

    2. Spring Security

    Spring Security 는 사용자 정보 (ID/PW) 검증 및 유저 정보 관리 등을 쉽게 사용할 수 있도록 제공.

    스프링 시큐리티는 원래 세션 기반 인증을 사용하기 때문에 JWT 와 별개로 생각해야한다.

    • User Role 을 꼭 설정해야 하나요?
      • Spring Security 자체에서 내부적으로 사용하는 것 같음
      • ROLE_USER 처럼 정확히 형식을 지켜줘야 함
    // build.gradle
    plugins {
        id 'org.springframework.boot' version '2.4.3'
        id 'io.spring.dependency-management' version '1.0.11.RELEASE'
        id 'java'
    }
    
    group = 'com.tutorial'
    version = '0.0.1-SNAPSHOT'
    sourceCompatibility = '11'
    
    configurations {
        compileOnly {
            extendsFrom annotationProcessor
        }
    }
    
    repositories {
        mavenCentral()
    }
    
    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    
            // security 관련 의존성
        implementation 'org.springframework.boot:spring-boot-starter-security'
    
        implementation 'org.springframework.boot:spring-boot-starter-web'
        compileOnly 'org.projectlombok:lombok'
        runtimeOnly 'com.h2database:h2'
        annotationProcessor 'org.projectlombok:lombok'
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
        testImplementation 'org.springframework.security:spring-security-test'
    
            // jwt 관련 의존성
        compile group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
        runtime group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
        runtime group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
    }
    
    test {
        useJUnitPlatform()
    }

     

    3. Member 도메인 설계

    시큐리티 설정을 테스트하기 위한 기본적인 사용자 도메인을 생성. 시큐리티 자체적으로 UserDetails 의 구현체인 User 를 사용하기 때문에 헷갈리지 않도록 Account 또는 Member 로 이름 짓는게 좋음.

    • Member 도메인
      • Member
      • MemberRepository
      • MemberService
      • MemberController
      • application.yml: maria 설정과 jwt secret key 설정

     

    3.1. Member

    @Getter
    @NoArgsConstructor
    @Table(name = "member")
    @Entity
    public class Member {
    
        @Id
        @Column(name = "member_id")
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String email;
    
        private String password;
    
        @Enumerated(EnumType.STRING)
        private Authority authority;
    
        @Builder
        public Member(String email, String password, Authority authority) {
            this.email = email;
            this.password = password;
            this.authority = authority;
        }
    }
    • 최소한의 정보만을 갖고 있는 Member Entity 입니다.

     

    public enum Authority {
        ROLE_USER, ROLE_ADMIN
    }
    • 권한은 Enum 클래스로 만들었음.

     

    3.2. MemberRepository

    @Repository
    public interface MemberRepository extends JpaRepository<Member, Long> {
        Optional<Member> findByEmail(String email);
        boolean existsByEmail(String email);
    }
    • 마찬가지로 최소한의 쿼리만 갖고있다.
    • Email 을 Login ID 로 갖고 있기 때문에 findByEmail 와 중복 가입 방지를 위한 existsByEmail 만 추가.

     

    3.3. MemberService

    @Service
    @RequiredArgsConstructor
    public class MemberService {
        private final MemberRepository memberRepository;
    
        @Transactional(readOnly = true)
        public MemberResponseDto getMemberInfo(String email) {
            return memberRepository.findByEmail(email)
                    .map(MemberResponseDto::of)
                    .orElseThrow(() -> new RuntimeException("유저 정보가 없습니다."));
        }
    
        // 현재 SecurityContext 에 있는 유저 정보 가져오기
        @Transactional(readOnly = true)
        public MemberResponseDto getMyInfo() {
            return memberRepository.findById(SecurityUtil.getCurrentMemberId())
                    .map(MemberResponseDto::of)
                    .orElseThrow(() -> new RuntimeException("로그인 유저 정보가 없습니다."));
        }
    }
    • 내 정보를 가져올 때는 SecurityUtil.getCurrentMemberId() 를 사용.
    • API 요청이 들어오면 필터에서 Access Token 을 복호화 해서 유저 정보를 꺼내 SecurityContext 라는 곳에 저장.
    • SecurityContext 에 저장된 유저 정보는 전역으로 어디서든 꺼낼 수 있음.
    • SecurityUtil 클래스에서는 유저 정보에서 Member ID 만 반환하는 메소드가 정의되어 있음.

     

    3.4. MemberController

    @RestController
    @CrossOrigin(origins = "http://localhost:8080") // 추가
    @RequiredArgsConstructor
    @RequestMapping("/member")
    public class MemberController {
        private final MemberService memberService;
    
        @GetMapping("/me")
        public ResponseEntity<MemberResponseDto> getMyMemberInfo() {
            return ResponseEntity.ok(memberService.getMyInfo());
        }
    
        @PostMapping("/me")
        public ResponseEntity<MemberResponseDto> getMyMemberInfo2() {
            return ResponseEntity.ok(memberService.getMyInfo());
        }
    
        @GetMapping("/{email}")
        public ResponseEntity<MemberResponseDto> getMemberInfo(@PathVariable String email) {
            return ResponseEntity.ok(memberService.getMemberInfo(email));
        }
    }
    • @CrossOrigin(origins = "http://localhost:8080") 
      • 8080 - 8081 두개를 이용했기 때문에 추가했다.

     

    3.5. application.yml

    server:
      port: 8081
      servlet:
        context-path: /
        encoding:
          charset: UTF-8
          enabled: true
          force: true
    
    spring:
      datasource:
        driver-class-name: org.mariadb.jdbc.Driver
        url: jdbc:mariadb://localhost:3306/mydb
        username: myadmin
        password: 5334914
    
      jpa:
        hibernate:
          ddl-auto: update #create update none
          naming:
            physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
        show-sql: true
    
    logging:
      level:
        com.tutorial: debug
    
    # HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
    # Secret 값은 특정 문자열을 Base64 로 인코딩한 값 사용 (아래 명령어를 터미널에 쳐보면 그대로 나옴)
    # $ echo 'spring-boot-security-jwt-tutorial-jiwoon-spring-boot-security-jwt-tutorial' | base64
    jwt:
      secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWppd29vbi1zcHJpbmctYm9vdC1zZWN1cml0eS1qd3QtdHV0b3JpYWwK
    • mariadb와 연결
    • 시크릿 키도 원래는 깃헙에 올라가지 않게 별도로 보관하는 것이 안전함.

     

    4. JWT 와 Security 설정

    • JWT 관련
      • TokenProvider: 유저 정보로 JWT 토큰을 만들거나 토큰을 바탕으로 유저 정보를 가져옴
      • JwtFilter: Spring Request 앞단에 붙일 Custom Filter
    • Spring Security 관련
      • JwtSecurityConfig: JWT Filter 를 추가
      • JwtAccessDeniedHandler: 접근 권한 없을 때 403 에러
      • JwtAuthenticationEntryPoint: 인증 정보 없을 때 401 에러
      • SecurityConfig: 스프링 시큐리티에 필요한 설정
      • SecurityUtil: SecurityContext 에서 전역으로 유저 정보를 제공하는 유틸 클래스

     

    4.1. TokenProvider

    import com.tutorial.jwtsecurity.controller.dto.TokenDto;
    import io.jsonwebtoken.*;
    import io.jsonwebtoken.io.Decoders;
    import io.jsonwebtoken.security.Keys;
    import lombok.extern.slf4j.Slf4j;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    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 java.security.Key;
    import java.text.DateFormat;
    import java.text.SimpleDateFormat;
    import java.util.*;
    import java.util.stream.Collectors;
    
    @Slf4j
    @Component
    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 * 1;            // 30분
        private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일
    
        private final Key key;
    
        public TokenProvider(@Value("${jwt.secret}") String secretKey) {
            byte[] keyBytes = Decoders.BASE64.decode(secretKey);
            this.key = Keys.hmacShaKeyFor(keyBytes);
        }
    
        //토큰 만들기
        public TokenDto generateTokenDto(Authentication authentication) {
            // 권한들 가져오기
            String authorities = authentication.getAuthorities().stream()
                    .map(GrantedAuthority::getAuthority)
                    .collect(Collectors.joining(","));
    
            long now = (new Date()).getTime();
    
    
            // Access Token 생성
            Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
            String accessToken = Jwts.builder()
                    .setSubject(authentication.getName())       // payload "sub": "name"
                    .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "ROLE_USER"
                    .setExpiration(accessTokenExpiresIn)        // payload "exp": 1516239022 (예시)
                    .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                    .compact();
    
            // Refresh Token 생성
            Date refreshTokenExpiresIn = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);
            String refreshToken = Jwts.builder()
                    .setExpiration(refreshTokenExpiresIn)
                    .signWith(key, SignatureAlgorithm.HS512)
                    .compact();
    
            return TokenDto.builder()
                    .grantType(BEARER_TYPE)
                    .accessToken(accessToken)
                    .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                    .refreshTokenExpiresIn(refreshTokenExpiresIn.getTime())
                    .refreshToken(refreshToken)
                    .build();
        }
    
        //토큰에 담겨있는 정보를 이용해 authentication 객체를 리턴하는 메소드 생성
       public Authentication getAuthentication(String accessToken) {
            // 토큰 복호화
            Claims claims = parseClaims(accessToken);
    
            if (claims.get(AUTHORITIES_KEY) == null) {
                throw new RuntimeException("권한 정보가 없는 토큰입니다.");
            }
    
            // 클레임에서 권한 정보 가져오기
            Collection<? extends GrantedAuthority> authorities =
                    Arrays.stream(claims.get(AUTHORITIES_KEY).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("잘못된 JWT 서명입니다.");
            } catch (ExpiredJwtException e) {
                log.info("만료된 JWT 토큰입니다.");
            } catch (UnsupportedJwtException e) {
                log.info("지원되지 않는 JWT 토큰입니다.");
            } catch (IllegalArgumentException e) {
                log.info("JWT 토큰이 잘못되었습니다.");
            }
            return false;
        }
    
        private Claims parseClaims(String accessToken) {
            try {
                return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
            } catch (ExpiredJwtException e) {
                return e.getClaims();
            }
        }
    }
    • JWT 토큰에 관련된 암호화, 복호화, 검증 로직은 다 이곳에서 이루어진다.
    • 생성자
      • application.yml 에 정의해놓은 jwt.secret 값을 가져와서 JWT 를 만들 때 사용하는 암호화 키값을 생성.
    • generateTokenDto
      • 유저 정보를 넘겨받아서 Access Token 과 Refresh Token 을 생성.
      • 넘겨받은 유저 정보의 authentication.getName() 메소드가 username 을 가져온다.
      • 저는 username 으로 Member ID 를 저장했기 때문에 해당 값이 설정.
      • Access Token 에는 유저와 권한 정보를 담고 Refresh Token 에는 아무 정보도 담지 않음.
    • getAuthentication
      • JWT 토큰을 복호화하여 토큰에 들어 있는 정보를 꺼냄.
      • Access Token 에만 유저 정보를 담기 때문에 명시적으로 accessToken 을 파라미터로 받게 함.
      • Refresh Token 에는 아무런 정보 없이 만료일자만 담기.
      • Access Token 에도 아무런 정보 없이 만료일자만 담기.
      • UserDetails 객체를 생생성해서 UsernamePasswordAuthenticationToken 형태로 리턴하는데 SecurityContext 를 사용하기 위한 절차
      • 사실 좀 불필요한 절차라고 생각되지만 SecurityContext  Authentication 객체를 저장하기 때문에 어쩔수 없다.
      • parseClaims 메소드는 만료된 토큰이어도 정보를 꺼내기 위해서 따로 분리.
    • validateToken
      • 토큰 정보를 검증.
      • Jwts 모듈이 알아서 Exception 을 던짐.

     

    4.2. JwtFilter

    @RequiredArgsConstructor
    public class JwtFilter extends OncePerRequestFilter {
    
        public static final String AUTHORIZATION_HEADER = "Authorization";
        public static final String BEARER_PREFIX = "Bearer ";
    
        private final TokenProvider tokenProvider;
    
        // 실제 필터링 로직은 doFilterInternal 에 들어감
        // JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
    
            // 1. Request Header 에서 토큰을 꺼냄
            String jwt = resolveToken(request);
    
            // 2. validateToken 으로 토큰 유효성 검사
            // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
            if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
                Authentication authentication = tokenProvider.getAuthentication(jwt);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
    
            filterChain.doFilter(request, response);
        }
    
        // Request Header 에서 토큰 정보를 꺼내오기
        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;
        }
    }
    • OncePerRequestFilter 인터페이스를 구현하기 때문에 요청 받을 때 단 한번만 실행됩니다.
    • doFilterInternal
      • 실제 필터링 로직을 수행하는 곳입니다.
      • Request Header 에서 Access Token 을 꺼내고 여러가지 검사 후 유저 정보를 꺼내서 SecurityContext 에 저장합니다.
      • 가입/로그인/재발급을 제외한 모든 Request 요청은 이 필터를 거치기 때문에 토큰 정보가 없거나 유효하지 않으면 정상적으로 수행되지 않습니다.
      • 그리고 요청이 정상적으로 Controller 까지 도착했다면 SecurityContext 에 Member ID 가 존재한다는 것이 보장됩니다.
      • 대신 직접 DB 를 조회한 것이 아니라 Access Token 에 있는 Member ID 를 꺼낸 거라서, 탈퇴로 인해 Member ID 가 DB 에 없는 경우 등 예외 상황은 Service 단에서 고려해야 합니다.

     

    4.3. JwtSecurityConfig

    // 직접 만든 TokenProvider 와 JwtFilter 를 SecurityConfig 에 적용할 때 사용
    @RequiredArgsConstructor
    public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
        private final TokenProvider tokenProvider;
    
        // TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록
        @Override
        public void configure(HttpSecurity http) {
            JwtFilter customFilter = new JwtFilter(tokenProvider);
            http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
        }
    }
    • SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> 인터페이스를 구현하는 구현체입니다.
    • 여기서 직접 만든 JwtFilter 를 Security Filter 앞에 추가합니다.

     

    4.4. JwtAuthenticationEntryPoint

    @Component
    public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
            // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
        }
    }
    • 유저 정보 없이 접근하면 SC_UNAUTHORIZED (401) 응답을 내려줍니다.

     

    4.5. JwtAccessDeniedHandler

    @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);
        }
    }
    • 유저 정보는 있으나 자원에 접근할 수 있는 권한이 없는 경우 SC_FORBIDDEN (403) 응답을 내려줍니다.

     

    4.6. SecurityConfig

    @EnableWebSecurity
    @RequiredArgsConstructor
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
        private final TokenProvider tokenProvider;
        private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
        private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        // h2 database 테스트가 원활하도록 관련 API 들은 전부 무시
        @Override
        public void configure(WebSecurity web) {
            web.ignoring()
                .antMatchers("/h2-console/**", "/favicon.ico");
        }
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
                // CSRF 설정 Disable
            http.csrf().disable()
    
                // exception handling 할 때 우리가 만든 클래스를 추가
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)
    
                // h2-console 을 위한 설정을 추가
                .and()
                .headers()
                .frameOptions()
                .sameOrigin()
    
                // 시큐리티는 기본적으로 세션을 사용
                // 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    
                // 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
                .and()
                .authorizeRequests()
                .antMatchers("/auth/**").permitAll()
                .anyRequest().authenticated()   // 나머지 API 는 전부 인증 필요
    
                // JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
                .and()
                .apply(new JwtSecurityConfig(tokenProvider));
        }
    }
    • WebSecurityConfigurerAdapter 인터페이스의 구현체입니다.
    • Spring Security 의 가장 기본적인 설정이며 JWT 를 사용하지 않더라도 이 설정은 기본으로 들어갑니다.
    • 오버라이드한 configure 내부에서 각종 설정들을 추가해줍니다.
    • 각 설정에 대한 설명은 주석을 확인하면 됩니다.

     

    4.7. SecurityUtil

    @Slf4j
    public class SecurityUtil {
    
        private SecurityUtil() { }
    
        // SecurityContext 에 유저 정보가 저장되는 시점
        // Request 가 들어올 때 JwtFilter 의 doFilter 에서 저장
        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());
        }
    }
    • JwtFilter 에서 SecurityContext 에 세팅한 유저 정보를 꺼냅니다.
    • 저는 무조건 memberId 를 저장하게 했으므로 꺼내서 Long 타입으로 파싱하여 반환합니다.
    • SecurityContext  ThreadLocal 에 사용자의 정보를 저장합니다.

     

    5. Refresh Token 저장소

    Access Token 과 Refresh Token 을 함께 사용하기 때문에 저장이 필요합니다.

    보통은 Token 이 만료될 때 자동으로 삭제 처리 하기 위해 Redis 를 많이 사용하지만, 귀찮으니 일단 임시로 RDB 에 저장하는 방식으로 구현했습니다.

    만약 지금 예제처럼 RDB 를 저장소로 사용한다면 배치 작업을 통해 만료된 토큰들을 삭제해주는 작업이 필요합니다.

     

    5.1. RefreshToken

    @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;
        }
    }
    • key 에는 Member ID 값이 들어갑니다.
    • value 에는 Refresh Token String 이 들어갑니다.
    • 위에서 언급한대로 RDB 로 구현하게 된다면 생성/수정 시간 컬럼을 추가하여 배치 작업으로 만료된 토큰들을 삭제해주어야 합니다.

     

    5.2. RefreshTokenRepository

    @Repository
    public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
        Optional<RefreshToken> findByKey(String key);
    }
    • Member ID 값으로 토큰을 가져오기 위해 findByKey 만 추가했습니다.

     

    6. 사용자 인증 과정

    지금까지 스프링 시큐리티와 JWT 를 사용하기 위한 설정들을 전부 끝냈습니다.

    지금부터는 실제로 사용자 로그인 요청이 들어왔을 때 인증 처리 후에 JWT 토큰을 발급하는 과정을 알아봅니다.

    • AuthController
    • AuthService
    • CustomUserDetailsService

     

    6.1. AuthController

    @RestController
    @RequestMapping("/auth")
    @RequiredArgsConstructor
    public class AuthController {
        private final AuthService authService;
    
        @PostMapping("/signup")
        public ResponseEntity<MemberResponseDto> signup(@RequestBody MemberRequestDto memberRequestDto) {
            return ResponseEntity.ok(authService.signup(memberRequestDto));
        }
    
        @PostMapping("/login")
        public ResponseEntity<TokenDto> login(@RequestBody MemberRequestDto memberRequestDto) {
            return ResponseEntity.ok(authService.login(memberRequestDto));
        }
    
        @PostMapping("/reissue")
        public ResponseEntity<TokenDto> reissue(@RequestBody TokenRequestDto tokenRequestDto) {
            return ResponseEntity.ok(authService.reissue(tokenRequestDto));
        }
    }
    • 회원가입 / 로그인 / 재발급 을 처리하는 API 입니다.
    • SecurityConfig 에서 /auth/** 요청은 전부 허용했기 때문에 토큰 검증 로직을 타지 않습니다.
    • MemberRequestDto 에는 사용자가 로그인 시도한 ID / PW String 이 존재합니다.
    • TokenRequestDto 에는 재발급을 위한 AccessToken / RefreshToken String 이 존재합니다.

     

    6.2. AuthService

    @Service
    @RequiredArgsConstructor
    public class AuthService {
        private final AuthenticationManagerBuilder authenticationManagerBuilder;
        private final MemberRepository memberRepository;
        private final PasswordEncoder passwordEncoder;
        private final TokenProvider tokenProvider;
        private final RefreshTokenRepository refreshTokenRepository;
    
        @Transactional
        public MemberResponseDto signup(MemberRequestDto memberRequestDto) {
            if (memberRepository.existsByEmail(memberRequestDto.getEmail())) {
                throw new RuntimeException("이미 가입되어 있는 유저입니다");
            }
    
            Member member = memberRequestDto.toMember(passwordEncoder);
            return MemberResponseDto.of(memberRepository.save(member));
        }
    
        @Transactional
        public TokenDto login(MemberRequestDto memberRequestDto) {
            // 1. Login ID/PW 를 기반으로 AuthenticationToken 생성
            UsernamePasswordAuthenticationToken authenticationToken = memberRequestDto.toAuthentication();
    
            // 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분
            //    authenticate 메서드가 실행이 될 때 CustomUserDetailsService 에서 만들었던 loadUserByUsername 메서드가 실행됨
            Authentication authentication = authenticationManagerBuilder.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. 저장소에서 Member ID 를 기반으로 Refresh Token 값 가져옴
            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)

    • Authentication
      • 사용자가 입력한 Login ID, PW 로 인증 정보 객체 UsernamePasswordAuthenticationToken를 생성합니다.
      • 아직 인증이 완료된 객체가 아니며 AuthenticationManager 에서 authenticate 메소드의 파라미터로 넘겨서 검증 후에 Authentication 를 받습니다.
    • AuthenticationManager
      • 스프링 시큐리티에서 실제로 인증이 이루어지는 곳입니다.
      • authenticate 메소드 하나만 정의되어 있는 인터페이스며 위 코드에서는 Builder 에서 UserDetails 의 유저 정보가 서로 일치하는지 검사합니다.
      • 그런데 코드상으로는 전혀 구현된게 없는데 어떻게 된 걸까요?
      • 내부적으로 수행되는 검증 과정은 아래의 CustomUserDetailsService 클래스에서 다루겠습니다.
    • 인증이 완료된 authentication 에는 Member ID 가 들어있습니다.
    • 인증 객체를 바탕으로 Access Token + Refresh Token 을 생성합니다.
    • Refresh Token 은 저장하고, 생성된 토큰 정보를 클라이언트에게 전달합니다.

     

    #재발급 (reissue)

    • Access Token + Refresh Token 을 Request Body 에 받아서 검증합니다.
    • Refresh Token 의 만료 여부를 먼저 검사합니다.
    • Access Token 을 복호화하여 유저 정보 (Member ID) 를 가져오고 저장소에 있는 Refresh Token 과 클라이언트가 전달한 Refresh Token 의 일치 여부를 검사합니다.
    • 만약 일치한다면 로그인 했을 때와 동일하게 새로운 토큰을 생성해서 클라이언트에게 전달합니다.
    • Refresh Token 은 재사용하지 못하게 저장소에서 값을 갱신해줍니다.

     

    6.3. CustomUserDetailsService

    @Service
    @RequiredArgsConstructor
    public class CustomUserDetailsService implements UserDetailsService {
    
        private final MemberRepository memberRepository;
    
        @Override
        @Transactional
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            return memberRepository.findByEmail(username)
                    .map(this::createUserDetails)
                    .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
        }
    
        // DB 에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴
        private UserDetails createUserDetails(Member member) {
            GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthority().toString());
    
            return new User(
                    String.valueOf(member.getId()),
                    member.getPassword(),
                    Collections.singleton(grantedAuthority)
            );
        }
    }
    • UserDetailsService 인터페이스를 구현한 클래스입니다.
    • loadUserByUsername 메소드를 오버라이드 하는데 여기서 넘겨받은 UserDetails  Authentication 의 패스워드를 비교하고 검증하는 로직을 처리합니다.
    • 물론 DB 에서 username 을 기반으로 값을 가져오기 때문에 아이디 존재 여부도 자동으로 검증 됩니다.
    • loadUserByUsername 메소드를 어디서 호출하는지 내부를 타고 들어가봅니다.

     

    6.3.1. CustomUserDetailsService

    loadUserByUsername 는 여러 곳에서 호출하고 있는데 이 중에서 DaoAuthenticationProvider 내부를 확인해봅니다.

     

    6.3.2. DaoAuthenticationProvider

    • username 을 받아서 넘겨주는 retrieveUser 메소드 내부에서 호출합니다.
    • 그럼 이 retrieveUser 는 어디서 호출할까요?

     

    6.3.3. AbstractUserDetailsAuthenticationProvider

    • DaoAuthenticationProvider 의 부모 클래스인 AbstractUserDetailsAuthenticationProvider 에서 호출합니다.
    • 코드를 쭉 보니 받아온 user 변수로 additionalAuthenticationChecks 메소드를 호출합니다.
    • 메소드를 확인해보니 추상 클래스였고, DaoAuthenticationProvider 를 다시 확인해보니 오버라이드 해서 구현이 되어 있었습니다.

     

    6.3.4. 다시 DaoAuthenticationProvider

    • 실제로 비밀번호 검증이 이루어지는 부분입니다 !
    • Request 로 받아서 만든 authentication 와 DB 에서 꺼낸 값인 userDetails 의 비밀번호를 비교합니다.
    • DB 에 있는 값은 암호화된 값이고 사용자가 입력한 값은 raw 값이지만 passwordEncoder 가 알아서 비교해줍니다.
    • 그래서 결국 비밀번호 검증이 시큐리티가 제공하는 클래스에서 이루어지는 것을 확인했는데 로그인 시에 사용되는 AuthenticationManager 와는 무슨 관계일까요?
    • AbstractUserDetailsAuthenticationProvider  authenticate 를 어디에서 호출하는지 확인해봅니다.

     

    • AbstractUserDetailsAuthenticationProvider  authenticate 는 단 한곳에서 호출합니다.

     

    6.3.5. ProviderManager

    • 여기서도 authenticate 라는 메소드네요.
    • AuthenticationProvider 라는 인터페이스에서 호출하는데요.
    • 이름으로 짐작할 수 있듯이 AbstractUserDetailsAuthenticationProvider 의 상위 인터페이스입니다.
    • 그리고 ProviderManager.authenticate 를 호출하는 곳을 확인해보니 드디어 찾을 수 있었습니다.

     

    6.3.6. AuthService

    • ProviderManager  AuthenticationManager 의 구현체입니다.
    • 지금까지의 탐구 과정을 역으로 다시 가보면 어떤 순서로 비밀번호 검증이 이루어지는 지 알 수 있습니다.

     

    1. AuthService (그림에서는 오타) 에서 AuthenticationManagerBuilder 주입 받음
    2. AuthenticationManagerBuilder 에서 AuthenticationManager 를 구현한 ProviderManager 생성
    3. ProviderManager 는 AbstractUserDetailsAuthenticationProvider 의 자식 클래스인 DaoAuthenticationProvider 를 주입받아서 호출
    4. DaoAuthenticationProvider 의 authenticate 에서는 retrieveUser 로 DB 에 있는 사용자 정보를 가져오고 additionalAuthenticationChecks 로 비밀번호 비교
    5. retrieveUser 내부에서 UserDetailsService 인터페이스를 직접 구현한 CustomUserDetailsService 클래스의 오버라이드 메소드인 loadUserByUsername 가 호출됨

     

    7. API 호출 테스트

    이제 서버를 띄우고 실제로 API 호출을 해봅니다.

    API 요청은 인텔리제이에 있는 http Tool 을 사용했습니다.

     

    7.1. 가입

    # Request
    POST http://localhost:8080/auth/signup
    Content-Type: application/json
    
    {
      "email": "test@test.net",
      "password": "1q2w3e4r"
    }
    
    # Response
    {
      "email": "test@test.net"
    }

     

    7.2. 로그인

    # Request
    POST http://localhost:8080/auth/login
    Content-Type: application/json
    
    {
      "email": "test@test.net",
      "password": "1q2w3e4r"
    }
    
    # Response
    {
      "grantType": "bearer",
      "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDI4MH0.43LvabP41Awhicy6YYAYHtDPnxNYpEygtE-DjLaDjNpAxZf01Nx4xE_dGk0V4jBpjwCgKVGKZIMyEeIppwzARQ",
      "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2MTU3MTcyODB9.DKqk-EZVT0TJAvvHpSN8nClIHKq-k4KYMHpx-Ltf7V8OB6Og4D_dsYnr3Z4Rw7iR7ckv-ZWMyi5SkheESw-T0g",
      "accessTokenExpiresIn": 1615114280584
    }

     

    7.3. 일반 API 요청

    # Request
    GET http://localhost:8080/member/me
    Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDI4MH0.43LvabP41Awhicy6YYAYHtDPnxNYpEygtE-DjLaDjNpAxZf01Nx4xE_dGk0V4jBpjwCgKVGKZIMyEeIppwzARQ
    
    # Response
    {
      "email": "test@test.net"
    }
    • 사용자 요청 -> JwtFiletr (SecurityContext 세팅) -> Controller -> Service

     

    7.4. 재발급

    # Request
    POST http://localhost:8080/auth/reissue
    Content-Type: application/json
    
    {
      "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDI4MH0.43LvabP41Awhicy6YYAYHtDPnxNYpEygtE-DjLaDjNpAxZf01Nx4xE_dGk0V4jBpjwCgKVGKZIMyEeIppwzARQ",
      "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2MTU3MTcyODB9.DKqk-EZVT0TJAvvHpSN8nClIHKq-k4KYMHpx-Ltf7V8OB6Og4D_dsYnr3Z4Rw7iR7ckv-ZWMyi5SkheESw-T0g"
    }
    
    # Response
    {
      "grantType": "bearer",
      "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDM2NX0.5VXa6Cht_DPEEGe7-BrElvsrs7qRXmVnkDdi4Lm3PxZ0vAgqFdirhe5RlE1D-Wc1zaUepBmGhhw-u-oP_-rbKQ",
      "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2MTU3MTczNjV9.tZytWyCWkWIYitvT3pa8FSnxilBDMtSevUzKRFK21TGLITf2eLXEwNNS_Q7rylD9uUe3Rx9ZR2NVqE_ZNWxTqg",
      "accessTokenExpiresIn": 1615114365284
    }

    댓글

Designed by Tistory.