스터디 4주차 내용 정리 (09장-10.1장)

박서영·2026년 2월 14일

09장. JWT로 로그인/로그아웃 구현하기

9.1 사전 지식: 토큰 기반 인증

토큰 기반 인증이란?

  • 사용자가 서버에 접근할 때 사용자가 인증된 사용자인지 확인하는 방법 (= 사용자 인증 확인 방법)

    • 서버 기반 인증
    • 토큰 기반 인증
  • 스프링부트 시큐리티에서 기본적으로 ‘세션 기반 인증’을 제공해줌. 8장에서는 이를 활용해 사용자의 정보를 담은 세션 생성/저장하여 인증 진행함

  • 토큰 기반 인증:

    • 토큰: 서버에서 클라이언트를 구분하기 위한 유일한 값
    • 서버가 토큰을 생성해 클라이언트에게 제공하면, 클라이언트는 토큰을 갖고 있다가 여러 요청을 이 토큰과 함께 신청 → 서버는 토큰만 보고 유효한 사용자인지 검증
  • 토큰을 전달하고 인증받는 과정

    요청과 응답에 토큰을 함께 보냄.

    • 1- 클라이언트가 아이디와 비밀번호를 서버에 전달해 인증 요청
    • 2- 서버는 아이디와 비밀번호를 확인해 유효한 사용자인지 검증 → 유효한 사용자인 경우 토큰을 생성해 응답
    • 3- 클라이언트는 서버에서 준 토큰을 저장
    • 4- 인증이 필요한 API를 사용할 때 토큰을 함께 보냄
    • 5- 서버는 토큰이 유효한지 검증
    • 6- 토큰이 유효하면 클라이언트가 요청한 내용 처리
  • 토큰 기반 인증의 특징

    대표적으로 ‘무상태성, 확장성, 무결성’이라는 특징이 존재

    • 무상태성
      • 사용자의 인증 정보가 담겨있는 토큰이 서버가 아닌 클라이언트에 있으므로 서버에 저장할 필요가 없음.
      • 토큰 기반 인증에서는 클라이언트에서 인증 정보가 담긴 토큰을 생성하고 인증 → 클라이언트에서 사용자의 인증 상태를 유지하면서 이후 요청을 처리해야함 ⇒ “상태 관리”
      • 서버에 데이터를 유지하려면 자원을 소비해야하는데, 이렇게 서버에서 클라이언트의 인증 정보를 저장/유지하지 않아도 되면 완전한 “무상태”로 효율적인 검증 가능
    • 확장성
      • 무상태성은 확장성에 영향을 줌
      • 서버를 확장할 때 상태 관리를 신경쓸 필요가 없기에 서버 확장에 용이함
      • 예) 물건 판매 서비스 + 결제를 위한 서버와 주문을 위한 서버 분리 가정 세션 인증 기반 - 각각 API에서 인증을 해야함 토큰 기반 인증 - 토큰을 가지는 주체가 서버가 아니라 클라이언트이기에 하나의 토큰으로 결제 서버와 주문 서버에 요청을 보낼 수 있음.
      • 추가로 페이스북 로그인, 구글 로그인과 같이 토큰 기반 인증을 사용하는 다른 시스템에 접근해 로그인 방식을 확장할 수 있고, 다른 서비스에 권한을 공유할 수 있음
    • 무결성
      • 토큰 방식은 HMAC 기법이라고도 불림
      • 토큰을 발급한 이후에는 토큰 정보를 변경하는 행위를 할 수 없음 ⇒ 즉, 토큰의 무결성이 보장됨
      • 누군가 토큰을 한 글자라도 변경하면 서버에서는 유효하지 않은 토큰이라고 판단

JWT

  • 발급받은 JWT를 이용해 인증하기 위해서는 HTTP 요청 헤더 중에 Authorization 키값에 Bearer + JWT 토큰값을 넣어 보내야함

  • JWT의 구조: .을 기준으로 헤더 + 내용 + 서명으로 구성

    1. 등록된 클레임: 토큰에 대한 정보를 담는 데에 사용

      이름설명
      iss토큰 발급자 (issuer)
      sub토큰 제목 (subject)
      aud토큰 대상자 (audience)
      exp토큰의 만료시간(expiration), 시간은 NumericDate 형식으로하며, 현재 시간 이후로 설정
      nbf토큰의 활성 날짜와 비슷한 개념으로 nbf는 Not Before을 의미. NumericDate 형식으로 날짜를 지정하며, 해당 날짜가 지나기 전까지는 토큰이 처리되지 않음
      iat토큰이 발급된 시간으로 iat는 issued at을 의미함
      jtiJWT의 고유 식별자로서 주로 일회용 토큰에 사용함
    2. 공개 클레임: 공개되어도 상관없는 클레임을 의미. 충돌을 방지할 수 있는 이름을 가져야하며, 보통 클레임 이름을 URI로 지음

    3. 비공개 클레임: 공개되면 안되는 클레임을 의미하며 클라이언트와 서버 간의 통신에 사용됨

      {
      	"iss": "ajufresh@gmail.com", //등록된 클레임
      	"iat": 1622370878, //등록된 클레임
      	"exp": 1622372678, //등록된 클레임
      	"https://sumyoung.com/jwt_claims/is_admin":true, //공개 클레임
      	"email": "test@example.com", //비공개 클레임
      	"hello": "안녕하세요" //비공개 클레임
      }

      ⇒ iss, iat, exp는 JWT 자체에서 등록된 클레임. URI로 네이밍된 공개 클레임. 등록된 클레임도, 공개 클레임도 아닌 email과 hello는 비공개 클레임

    • 서명: 해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 사용. 헤더의 인코딩값과 내용의 인코딩값을 합친 후에 주어진 비밀키를 사용해 해시값을 생성.
  • 토큰 유효기간

    토큰을 주고받는 환경이 보안에 취약해 토큰 자체가 노출되면 → 토큰은 발급된 이후 그 자체로 인증 수단이 되기에 서버는 토큰과 함께 들어온 요청이 토큰을 탈취한 사람의 요청인지 확인할 수 없음

    • 리프레시 토큰: 액세스 토큰과 별개의 토큰으로 사용자를 인증하기 위한 용도가 아닌 액세스 토큰이 만료되었을 때, 새로운 액세스 토큰을 발급하기 위해 사용.

      • 액세스 토큰의 유효기간을 짧게 설정하고, 리프레시 토큰의 유효 기간을 길게 설정하면 공격자가 액세스 토큰을 탈취해도 몇 분 뒤에는 사용할 수 없는 토큰이 되기에 안전

        1- 클라이언트가 서버에게 인증 요청

        2- 서버는 클라이언트에서 전달한 정보를 바탕으로 인증 정보가 유효한지 확인한 뒤, 액세스 토큰과 리프레시 토큰을 만들어 클라이언트에게 전달 & 클라이언트는 전달받은 토큰을 저장

        3-서버에서 생성한 리프레시 토큰은 DB에 저장

        4- 인증을 필요로 하는 API를 호출할 때 클라이언트에 저장된 액세스 토큰과 함께 API 요청

        5- 서버는 전달받은 액세스 토큰이 유효한지 검사한 뒤에 유효하다면, 클라이언트에서 요청한 내용을 처리

        6- 시간이 지나고 액세스 토큰이 만료된 뒤에 클라이언트에서 원하는 정보를 얻기 위해 서버에게 API 요청을 보냄

        7- 서버는 액세스 토큰이 유효한지 검사 ⇒ 만료된 토큰이면 유효하지 않기에, 토큰이 만료되었다는 에러 전달

        8- 클라이언트는 응답을 받은 뒤, 리프레시 토큰과 함께 새로운 액세스 토큰 발급 요청을 전송

        9- 서버는 전달받은 리프레시 토큰이 유효한지, DB에 저장한 리프레시 토큰과 같은지 확인

        10- 유효한 리프레시 토큰이면 새로운 액세스 토큰을 생성한 뒤 응답

9.2 JWT 서비스 구현하기

목표: 실제 JWT 생성, 검증하는 서비스 구현하기

과정: 의존성과 토큰 제공자를 추가 → 리프레시 토큰 도메인과 토큰 필터를 구현

(10장에서 구현한 OAuth 서비스에서 이 장의 클래스들 사용할 예정)

의존성 추가하기

dependencies {
	  ...
    testAnnotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.projectlombok:lombok'
    implementation 'io.jsonwebtoken:jjwt:0.9.1' //자바 JWT 라이브러리
    implementation 'javax.xml.bind:jaxb-api:2.3.1' //XML 문서와 Java 객체 간 매핑 자동화
}
  • 자바에서 JWT를 사용하기 위한 라이브러리를 추가, XML 문서와 자바 객체 간 매핑을 자동화하는 jax-api를 추가

토큰 제공자 추가하기

jwt를 사용해서 JWT를 생성하고 유효한 토큰인지 검증하는 역할을 하는 클래스 추가

  • JWT 토큰을 만들려면 이슈발급자(issuer), 비밀키(secret_key)를 필수로 설정해야함
    spring:
      jpa:
        #전송 쿼리 확인
        show-sql: true
        properties:
          hibernate:
            format_sql: true
    
        #테이블 생성 후에 data.sql 실행
        defer-datasource-initialization: true
    
        datasource:
          url: jdbc:h2:mem:testdb
          username: sa
    
        h2:
          console:
            enabled: true
    
      jwt:
        issuer: test@example.com
        secret_key: study-springboot
  • 해당 값들을 변수로 접근하는데 사용할 JwtProperties 클래스 만들기:
    • config/jwt 패키지에 JwtProperties.java 파일을 만들어 코드 작성

      ⇒ issuer 필드에는 application.yml 파일의 jwt.issuer 값, secretKey에는 jwt.secret_key 값이 매핑됨
      @Setter
      @Getter
      @Component
      @ConfigurationProperties("jwt") //자바 클래스에 프로퍼티값을 가져와서 사용하는 애너테이션
      public class JwtProperties {
          private String issuer;
          private String secretKey;
      }
  • 계속해서 토큰을 생성하고, 올바른 토큰인지 유효성을 검사하고, 토큰에서 필요한 정보를 가져오는 클래스 작성하기

    • config/jwt 패키지에 TokenProvider.java 파일 만들어 작성

      @RequiredArgsConstructor
      @Service
      public class TokenProvider {
      
          private final JwtProperties jwtProperties;
      
          public String generateToken (User user, Duration expiredAt) {
              Date now = new Date();
              return makeToken(new Date(now.getTime()+expiredAt.toMillis()), user);
          }
      
          //1: JWT 토큰 생성 메서드
          private String makeToken (Date expiry, User user) {
              Date now = new Date();
      
              return Jwts.builder()
                      .setHeaderParam(Header.TYPE, Header.JWT_TYPE) //헤더 typ: JWT
                      //내용 iss: test@example.com (propertise 파일에서 설정한 값)
                      .setIssuer(jwtProperties.getIssuer())
                      .setIssuedAt(now) //내용 iat: 현재 시간
                      .setExpiration(expiry) //내용 exp: expiry 멤버 변수값
                      .setSubject(user.getEmail())  //내용 sub: 유저의 이메일
                      .claim("id", user.getId()) //클레임id: 유저 ID
                      //서명: 비밀값과 함께 해시값을 HS526 방식으로 암호화
                      .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                      .compact();
          }
      
          //2: JWT 토큰 유효성 검증 메소드
          public boolean validToken (String token) {
              try {
                  Jwts.parser()
                          .setSigningKey(jwtProperties.getSecretKey()) //비밀값으로 복호화
                          .parseClaimsJws(token);
      
                  return true;
              } catch (Exception e) {
                  return false;
              }
          }
      
          //3: 토큰 기바으로 인증 정보를 가져오는 메소드
          public Authentication getAuthentication (String token) {
              Claims claims = getClaims(token);
              Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
      
              return new UsernamePasswordAuthenticationToken(new org.springframework.security.core.userdetails.User(claims.getSubject(), "", authorities), token, authorities);
          }
          
          //4: 토큰 기반으로 유저 ID를 가져오는 메소드
          public Long getUserId (String token) {
              Claims claims = getClaims(token);
              return claims.get("id", Long.class);
          }
          
          private Claims getClaims (String token) {
              return Jwts.parser() //클레임 조회
                      .setSigningKey(jwtProperties.getSecretKey())
                      .parseClaimsJws(token)
                      .getBody();
          }
      }
    • 1: makeToken(): 토큰을 생성하는 메소드. 인자는 만료시간, 유저 정보를 받음.

      • 해당 메소드에서는 set 계열의 메소드를 통해 여러 값을 지정함.
      • 헤더: typ(타입), iss(발급자), iat(발급일시), exp(만료 일시), sub(토큰 제목)를 지정
      • 클레임: 유저 ID를 지정
      • 토큰을 만들 때에는 프로퍼티즈 파일에 선언해둔 비밀값과 함께 HS526방식으로 암호화
    • 2: validToken(): 토큰이 유효한지 검증하는 메소드. 프로퍼티즈 파일에 선언한 비밀값과 함께 토큰 복호화 진행. 만약 복호화 과정에서 에러가 발생하면 유효하지 않은 토큰이므로 false를 반환, 아무 에러도 발생하지 않으면 true 반환.

    • 3: getAuthentication(): 토큰을 받아 인증 정보를 담은 객체 Authentication을 반환하는 메소드.

      • 프로퍼티즈 파일에 저장한 비밀값으로 토큰을 복호화한 뒤 클레임을 가져오는 private 메소드
      • getClaims() 메소드를 호출해 클레임 정보를 반환받아 사용자 이메일이 들어있는 토큰 제목 sub와 토큰 기반으로 인증 정보 생성
    • 4: getUserId(): 토큰 기반으로 사용자 ID를 가져오는 메소드. 프로퍼티즈 파일에 저장한 비밀값으로 토큰을 복호화한 다음 클레임을 가져오는 private 메소드인 getClaims()를 호출해서 클레임 정보를 반환받고 클레임에서 id 키로 저장된 값을 가져와 반환

  • 테스트 코드 작성하기

    //JwtFactory.java 파일
    
    @Getter
    public class JwtFactory {
        private String subject = "test@example.com";
        private Date issuedAt = new Date();
        private Date expiration = new Date(new Date().getTime() + Duration.ofDays(14).toMillis());
        private Map<String, Object> claims = emptyMap();
    
        //빌더 패턴을 사용해 설정이 필요한 데이터만 선택 설정
        @Builder
        public JwtFactory (String subject, Date issuedAt, Date expiration, Map<String, Object> claims) {
            this.subject = subject != null ? subject:this.subject;
            this.issuedAt = issuedAt != null ? issuedAt:this.issuedAt;
            this.expiration = expiration != null ? expiration:this.expiration;
            this.claims = claims != null ? claims:this.claims;
        }
    
        public static JwtFactory withDefaultValues() {
            return JwtFactory.builder().build();
        }
    
        //jjwt 라이브러리를 사용해 JWT 토큰 생성
        public String createToken (JwtProperties jwtProperties) {
            return Jwts.builder()
                    .setSubject(subject)
                    .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                    .setIssuer(jwtProperties.getIssuer())
                    .setIssuedAt(issuedAt)
                    .setExpiration(expiration)
                    .addClaims(claims)
                    .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                    .compact();
        }
    }
    • 빌더 패턴을 사용해 객체를 만들 때 테스트가 필요한 데이터만 선택. 빌더 패턴을 사용하지 않으면 필드 기본값을 사용
  • TokenProvider 클래스를 테스트하는 클래스 만들기

    @SpringBootTest
    public class TokenProviderTest {
        @Autowired
        private TokenProvider tokenProvider;
        @Autowired
        private UserRepository userRepository;
        @Autowired
        private JwtProperties jwtProperties;
    
        //1: generateToken() 검증 테스트
        @DisplayName("generateToken(): 유저 정보와 만료 기간을 전달해 토큰을 만들 수 있음")
        @Test
        void generateToken() {
            //given
            User testUser = userRepository.save(User.builder()
                    .email("user@example.com")
                    .password("test")
                    .build());
    
            //when
            String token = tokenProvider.generateToken(testUser, Duration.ofDays(14));
    
            //then
            Long userId = Jwts.parser()
                    .setSigningKey(jwtProperties.getSecretKey())
                    .parseClaimsJws(token)
                    .getBody()
                    .get("id", Long.class);
    
            assertThat(userId).isEqualTo(testUser.getId());
        }
    
        //2: validToken() 검증 테스트
        @DisplayName("validToken(): 만료된 토큰인 때에 유효성 검증에 실패함")
        @Test
        void validToken_invalidToken() {
            //given
            String token = JwtFactory.builder()
                    .expiration(new Date(new Date().getTime() - Duration.ofDays(7).toMillis()))
                    .build()
                    .createToken(jwtProperties);
    
            //when
            boolean result = tokenProvider.validToken(token);
    
            //then
            assertThat(result).isFalse();
        }
    
        @DisplayName("validToken(): 유효한 토큰인 때에 유효성 검증에 성공한다.")
        @Test
        void validToken_validToken() {
            //given
            String token = JwtFactory.withDefaultValues()
                    .createToken(jwtProperties);
    
            //when
            boolean result = tokenProvider.validToken(token);
    
            //then
            assertThat(result).isTrue();
        }
    
        //3: getAuthentication() 검증 테스트
        @DisplayName("getAuthentication(): 토큰 기반으로 인증 정보를 가져올 수 있다.")
        @Test
        void getAuthentication() {
            //given
            String userEmail = "test@example.com";
            String token = JwtFactory.builder()
                    .subject(userEmail)
                    .build()
                    .createToken(jwtProperties);
    
            //when
            Authentication authentication = tokenProvider.getAuthentication(token);
    
            //then
            assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(userEmail);
        }
        
        //4: getUserId() 검증 테스트
        @DisplayName("getUserId(): 토큰으로 유저 ID를 가져올 수 있다.")
        @Test
        void getUserId() {
            //given
            Long userId = 1L;
            String token = JwtFactory.builder()
                    .claims(Map.of("id", userId))
                    .build()
                    .createToken(jwtProperties);
            
            //when
            Long userIdByToken = tokenProvider.getUserId(token);
            
            //then
            assertThat(userIdByToken).isEqualTo(userId);
        }
    
    }
    • 1- generateToken() 메소드: 토큰을 생성하는 메소드를 테스트하는 메소드
    • 2- validToken_invalidToken() 메소드: 토큰이 유효한 토큰인지 검증하는 메소드를 테스트하는 메소드. 검증 실패를 확인하는 메소드와 검증 실패를 확인하는 메소드로 2가지 존재
    • 3- getAuthentication() 메소드: 토큰을 전달받아 인증 정보를 담은 객체 Authentication을 반환하는 메소드를 테스트
    • 4- getUserId() 메소드: 토큰 기반으로 유저 ID를 가져오는 메소드를 테스트하는 메소드. 토큰을 프로퍼티즈 파일에 저장한 비밀값으로 복호화한 뒤 클레임을 가져오는 private 메소드인 getClaims() 를 호출해 클레임 정보를 반환받아 클레임에서 id 키로 저장된 값을 가져와 반환.

리프레시 토큰 도메인 구현하기

리프레시 토큰: 데이터베이스에 저장하는 정보. 따라서 엔티티와 리포지토리를 추가해야함. 만들 엔티티와 매핑되는 테이블 구조는 아래와 같음

컬럼명자료형null 허용 여부설명
idBIGINTN기본키일련번호, 기본키
user_idBIGINTN유저ID
refresh_tokenVARCHAR(55)N토큰값
  • domain 디렉토리에 RefreshToken.java 파일 추가하기
    @NoArgsConstructor(access= AccessLevel.PROTECTED)
    @Getter
    @Entity
    public class RefreshToken {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name= "id", updatable = false)
        private Long id;
    
        @Column (name="user_id", nullable = false, unique = true)
        private Long userId;
    
        @Column(name="refresh_token", nullable = false)
        private String refreshToken;
    
        public RefreshToken (Long userId, String refreshToken) {
            this.userId = userId;
            this.refreshToken = refreshToken;
        }
        
        public RefreshToken update (String newRefreshToken) {
            this.refreshToken = newRefreshToken;
            return this;
        }
    }
  • 리포지토리 만들기: RefreshTokenRepository.java 파일 repository 디렉토리에 생성하기
    public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
        
        Optional<RefreshToken> findByUserId (Long userId);
        Optional<RefreshToken> findByRefreshToken (String refreshToken);
    }

토큰 필터 구현하기

  • 토큰 필터: 각종 요청을 처리하기 위한 로직으로 전달되기 전후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공

요청이 오면 헤더값을 비교해서 토큰이 있는지 확인하고 유효 토큰이라면 시큐리티 콘텍스트 홀더에 인증 정보를 저장함

  • 시큐리티 컨텍스트: 인증 객체가 저장되는 보관소. 인증 정보가 필요할 때 언제든지 인증 객체를 꺼내서 사용할 수 있음. 스레드마다 공간을 할당 (= 스레드 로컬에 저장)되기에 코드 아무곳에서나 참조할 수 있고, 다른 스레드에서 공유하지 않기에 독립적으로 사용할 수 있음.

  • 시큐리티 홀더: 시큐리티 컨텍스트 객체를 저장하는 객체

  • config 디렉토리에 TokenAuthenticationFilter.java 파일 생성하기. 해당 필터는 액세스 토큰값이 담긴 Authorization 헤더값을 가져온 뒤 액세스 토큰이 유효하다면 인증 정보를 설정함

    @RequiredArgsConstructor
    public class TokenAuthenticationFilter extends OncePerRequestFilter {
    
        private final TokenProvider tokenProvider;
        private final static String HEADER_AUTHORIZATION = "Authorization";
        private final static String TOKEN_PREFIX = "Bearer";
    
        @Override
        protected void doFilterInternal (HttpServletRequest request,
                                         HttpServletResponse response,
                                         FilterChain filterChain) throws ServletException, IOException {
    
            //요청 헤더의 Authorization 키의 값 조회
            String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
            //가져온 값에서 접두사 제거
            String token = getAccessToken(authorizationHeader);
            //가져온 토큰이 유효한지 확인하고, 유효한 때는 인증 정보 설정
            if (tokenProvider.validToken(token)) {
                Authentication authentication = tokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
    
            filterChain.doFilter(request, response);
        }
    
        private String getAccessToken (String authorizationHeader) {
            if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
                return authorizationHeader.substring(TOKEN_PREFIX.length());
            }
    
            return null;
        }
    }
    • 요청 헤더에서 키가 Authorization인 필드의 값을 가져온 다음 토큰의 접두사 Bearer를 제외한 값을 얻음
      • 만약 값이 null이거나 Bearer로 시작하지 않으면 null을 반환
    • 가져온 토큰이 유효한지 확인하고, 유효하면 인증 정보를 관리하는 시큐리티 컨텍스트에 인증 정보를 설정
    • 위의 코드가 실행되면, 인증 정보가 설정된 이후에 컨텍스트 홀더에서 getAuthentication() 메소드를 사용해 인증 정보를 가져오면 유저 객체를 반환.
    • 유저 객체에는 유저 이름(username)과 권한목록(authorities)과 같은 인증 정보가 포함됨

9.3 토큰 API 구현하기

목표: 리프레시 토큰을 전달받아 토큰 제공자를 사용해 새로운 액세스 토큰을 만드는 토큰 서비스 클래스 생성하기

토큰 서비스 추가하기

리프레시 토큰을 전달받아 토큰 제공자를 사용해 새로운 액세스 토큰을 만드는 토큰 서비스 클래스 생성하기

  • UserService.java 파일에서 전달받은 유저 ID로 유저를 검색해서 전달하는 findById() 메소드를 추가로 구현하기
    public User findById (Long userId) {
            return userRepository.findById(userId)
                    .orElseThrow(() -> new IllegalArgumentException
                    ("Unexpected user"));
        }
  • service 디렉토리에 RefreshTokenService.java 파일을 새로 만들어 전달받은 리프레시 토큰으로 리프레시 토큰 객체를 검색해 전달하는 findByRefreshToken() 메소드 구현하기
    @RequiredArgsConstructor
    @Service
    public class RefreshTokenService {
        
        private final RefreshTokenRepository refreshTokenRepository;
        
        public RefreshToken findByRefreshToken (String refreshToken) {
            return refreshTokenRepository.findByRefreshToken(refreshToken)
                    .orElseThrow(() -> new IllegalArgumentException
                    ("Unexpected token"));
        }
    }
  • 토큰 서비스 클래스 생성하기: service 디렉토리에 TokenService.java 파일을 생성해 코드 작성하기.
    • createNewAccessToken() 메소드: 전달받은 리프레시 토큰으로 토큰 유효성 검사를 진행하고, 유효한 토큰일 때 리프레시 토큰으로 사용자 ID 찾기
    • 사용자 ID로 사용자를 찾은 후에 토큰 제공자의 generateToken() 메소드를 호출해 새로운 액세스 토큰을 생성
      @RequiredArgsConstructor
      @Service
      public class TokenService {
          
          private final TokenProvider tokenProvider;
          private final RefreshTokenService refreshTokenService;
          private final UserService userService;
          
          public String createNewAccessToken (String refreshToken) {
              //토큰 유효성 검사에 실패하면 예외 발생
              if (!tokenProvider.validToken(refreshToken)) {
                  throw new IllegalArgumentException("Unexpected token");
              }
              
              Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
              User user = userService.findById(userId);
              
              return tokenProvider.generateToken(user, Duration.ofHours(2));
          }
      }

컨트롤러 추가하기

목표: 실제 토큰을 발급받는 API 생성하기

  • dto 패키지에 토큰 생성 요청 및 응답을 담당할 DTO인 CreateAccessTokenRequest와 CreateAccessTokenResponse 클래스 만들기
    //CreateAccessTokenRequest.java
    
    @Getter
    @Setter
    public class CreateAccessTokenRequest {
        private String refreshToken;
    }
    //CreateAccessTokenResponse.java
    
    @AllArgsConstructor
    @Getter
    public class CreateAccessTokenResponse {
        private String accessToken;
    }
  • 실제로 요청을 받고 처리할 컨트롤러 생성하기: /api/token POST 요청이 오면 토큰 서비스에서 리프레시 토큰을 기반으로 새로운 액세스 토큰을 만들어주면됨.
    @RequiredArgsConstructor
    @RestController
    public class TokenApiController {
        
        private final TokenService tokenService;
        
        @PostMapping("/api/token")
        public ResponseEntity<CreateAccessTokenResponse> createNewAccessToken 
                (@RequestBody CreateAccessTokenRequest request) {
            String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken());
            
            return ResponseEntity.status(HttpStatus.CREATED)
                    .body(new CreateAccessTokenResponse(newAccessToken));
        }
    }
  • 테스트 코드 작성하기
    @SpringBootTest
    @AutoConfigureMockMvc
    class TokenApiControllerTest {
    
        @Autowired
        protected MockMvc mockMvc;
    
        @Autowired
        protected ObjectMapper objectMapper;
    
        @Autowired
        private WebApplicationContext context;
    
        @Autowired
        JwtProperties jwtProperties;
    
        @Autowired
        UserRepository userRepository;
    
        @Autowired
        RefreshTokenRepository refreshTokenRepository;
    
        @BeforeEach
        public void setUp() {
            jwtProperties.setSecretKey("study-springboot");  // 직접 주입
            jwtProperties.setIssuer("test@example.com");
        }
    
        @BeforeEach
        public void mockMvcSetUp() {
            this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                    .build();
            userRepository.deleteAll();
        }
    
        @DisplayName("createNewAccessToken: 새로운 액세스 토큰을 발급한다.")
        @Test
        public void createNewAccessToken() throws Exception {
            // given
            final String url = "/api/token";
    
            User testUser = userRepository.save(User.builder()
                    .email("user@gmail.com")
                    .password("test")
                    .build());
    
            String refreshToken = JwtFactory.builder()
                    .claims(Map.of("id", testUser.getId()))
                    .build()
                    .createToken(jwtProperties);
    
            refreshTokenRepository.save(new RefreshToken(testUser.getId(), refreshToken));
    
            CreateAccessTokenRequest request = new CreateAccessTokenRequest();
            request.setRefreshToken(refreshToken);
            final String requestBody = objectMapper.writeValueAsString(request);
    
            // when
            ResultActions resultActions = mockMvc.perform(post(url)
                    .contentType(MediaType.APPLICATION_JSON_VALUE)
                    .content(requestBody));
    
            // then
            resultActions
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.accessToken").isNotEmpty());
        }
    
    }

10장. OAuth2로 로그인/로그아웃 구현하기

10.1 사전 지식: OAuth

OAuth란?

OAuth란 제3의 서비스에 계정관리를 맡기는 방식으로 네이버로 로그인하기, 구글로 로그인하기와 같은 방법

✅ OAuth 용어 정리하기

  • 리소스 오너: 인증 서버에 자신의 정보를 사용하도록 허가하는 주체. 서비스를 이용하는 사용자가 리소스 오너에 해당함.
  • 리소스 서버: 리소스 오너의 정보를 가지며, 리소스 오너의 정보를 보호하는 주체를 의미함. 네이버, 구글, 페이스북이 리소스 서버에 해당
  • 인증 서버: 클라이언트에게 리소스 오너의 정보에 접근할 수 있는 토큰을 발급하는 역할을 하는 애플리케이션을 의미
  • 클라이언트 애플리케이션: 인증 서버에게 인증을 받고 리소스 오너의 리소스를 사용하는 주체를 의미.

OAuth를 사용하면 인증 서버에서 발급받은 토큰을 사용해서 리소스 서버에 리소스 오너의 정보를 요청하고 응답받아 사용할 수 있음. ⇒ 클라이언트가 리소스 오너의 정보를 취득하는 방법은?

✅ 리소스 오너의 정보를 취득하는 4가지 방법

  • 권한 부여 코드 승인 타입: OAuth 2.0에서 가장 잘 알려진 인증 방법으로 클라이언트가 리소스에 접근하는데 사용하며, 권한에 접근할 수 있는 코드와 리소스 오너에 대한 액세스 토큰을 발급받는 방식 ⇒ 사용자 데이터가 외부로 전송되지 않아 안전
  • 암시적 승인 타입: 서버가 없는 자바스크립트 웹 애플리케이션 클라이언트에서 주로 사용하는 방법. 클라이언트가 요청을 보내면 리소스 오너의 인증 과정 이외에는 권한 코드 교환 등의 별다른 인증 과정을 거치지 않고 액세스 토큰을 제공받는 방식
  • 리소스 소유자 암호 자격증명 타입: 클라이언트의 패스워드를 이용해 액세스 토큰에 대한 사용자의 자격 증명을 교환하는 방식
  • 클라이언트 자격증명 승인 타입: 클라이언트가 컨텍스트 외부에서 액세스 토큰을 얻어 특정 리소스에 접근을 요청할 때 사용하는 방식

권한 부여 코드 승인 타입이란?

  1. 권한 요청

    • 클라이언트(= 스프링부트 서버)가 특정 사용자 데이터에 접근하기 위해 권한 서버 (=카카오/구글)에 요청을 보내는 것을 의미함.
    • 요청 URI는 권한 서버마다 다르지만 보통 클라이언트 ID, 리다이렉트 URI, 응답 타입 등을 파라미터로 보냄
    [권한 요청을 위한 파라미터 예:]
    
    GET spring-authorization-server.example/authorize?
    	client_id=66a36bcd1&
    	redirect_uri=http://localhost:8080/myapp&
    	response_type=code&
    	scope=profile
    • client_id: 인증 서버가 클라이언트에 할당한 고유 식별자. 클라이언트 애플리케이션을 OAuth 서비스에 등록할 때 서비스에서 생성하는 값
    • redirect_uri: 로그인 성공 시 이동해야하는 URI
    • response_type: 클라이언트가 제공받길 원하는 응답 타입
    • scope: 제공받고자 하는 리소스 오너의 정보 목록
profile
이불 밖은 위험해.

0개의 댓글