본문 바로가기

Spring공부

로그인/로그아웃 구현하기 JWT

토큰기반 인증이란?

사용자가 서버에 접근할 때 이상요자가 인증된 상요자인지 확인하는 방법은 대표적으로 사용자 인증 확인 방법으로 서버 기반 인증과 토큰 기반 인증이 있다. 스프링 시큐리티에서는 기본적으로 세셔 기반 인증을 제공해준다. 사용자마다 사용자의 정보를 담은 세션을 생성하고 저장해서 인증을 했다. 이를 세션 기반 인증이라고 한다. 토큰 기반 인증은 토큰을 사용하는 방법이다. 토큰은 서버에서 클라인터를 구분하기 위한 유일한 값인데 서버가 토큰을 생성해서 클라이언트에게 제공하면, 클라이언트는 이토큰을 갖고 있다가 여러 요청을 이 토큰과 함께 신청한다. 그럼 서버는 토큰만 보고 유효한 사용자인지 검증한다.

 

토큰을 전달하고 인증받는 과정

  1. 인증 요청
    • 클라이언트: 아이디와 비밀번호를 서버에 전달하여 인증을 요청합니다.
  2. 인증 검증
    • 서버: 전달받은 아이디와 비밀번호를 확인하여 유효한 사용자인지 검증합니다.
  3. 토큰 생성
    • 서버: 사용자가 유효한 경우, 인증 토큰을 생성하여 클라이언트에게 응답으로 보냅니다.
  4. 토큰 저장
    • 클라이언트: 서버로부터 받은 토큰을 저장합니다 (예: 로컬 스토리지, 세션 스토리지 등).
  5. API 요청 시 토큰 전송
    • 클라이언트: 인증이 필요한 API를 호출할 때, 저장된 토큰을 함께 전송합니다.
  6. 토큰 검증
    • 서버: 클라이언트가 전송한 토큰의 유효성을 검증합니다.
  7. 요청 처리
    • 서버: 토큰이 유효한 경우, 클라이언트의 요청을 처리합니다.

이 과정을 통해, 클라이언트와 서버는 안전하게 인증된 상태에서 API를 사용할 수 있게 됩니다.

 

 

토큰 기반 인증의 특징

  1. 무상태성 (Stateless)
    • 정의: 사용자 인증 정보가 담긴 토큰을 서버가 아닌 클라이언트에 저장하므로, 서버에서는 이 정보를 저장할 필요가 없습니다.
    • 장점: 서버 자원을 절약할 수 있습니다. 서버는 클라이언트의 인증 상태를 유지할 필요 없이, 요청 시마다 토큰의 유효성만 검증하면 됩니다. 이는 서버 확장에도 용이합니다.
  2. 확장성
    • 무상태성과의 관계: 무상태성 덕분에 서버 확장이 용이해집니다. 상태 관리를 신경 쓸 필요가 없기 때문에, 여러 서버 간의 인증을 쉽게 처리할 수 있습니다.
    • 예시: 결제 서버와 주문 서버가 분리되어 있는 서비스에서, 클라이언트는 하나의 토큰으로 두 서버에 모두 요청을 보낼 수 있습니다. 또한, 페이스북 로그인이나 구글 로그인 같은 외부 토큰 기반 인증 시스템과도 연동이 가능합니다.
  3. 무결성 (Integrity)
    • 정의: 토큰 방식은 HMAC (Hash-based Message Authentication Code) 기법을 사용하여 토큰의 무결성을 보장합니다.
    • 장점: 토큰 발급 후에는 토큰 정보를 변경할 수 없으며, 변경 시 서버에서 유효하지 않은 토큰으로 간주됩니다. 이는 토큰의 안전성과 신뢰성을 높입니다.
    •  

JWT (JSON Web Token)

JWT는 JSON 포맷을 사용하여 정보를 안전하게 전달하기 위한 웹 토큰입니다.

JWT 구조

JWT는 세 부분으로 나뉩니다:

  1. 헤더 (Header)
  2. 내용 (Payload)
  3. 서명 (Signature)

1. 헤더 (Header)

헤더에는 토큰의 타입과 해싱 알고리즘을 지정하는 정보가 포함됩니다. 예를 들어, JWT 토큰 타입과 HS256 해싱 알고리즘을 사용하는 경우 다음과 같이 구성됩니다:

{ "typ": "JWT", "alg": "HS256" }
  • typ: 토큰의 타입을 지정합니다. JWT의 경우 "JWT" 문자열이 들어갑니다.
  • alg: 해싱 알고리즘을 지정합니다. 예를 들어, "HS256"은 HMAC SHA-256을 의미합니다.

2. 내용 (Payload)

내용에는 토큰과 관련된 정보를 담고 있으며, 각 정보를 클레임(Claim)이라고 부릅니다. 클레임은 키-값 쌍으로 이루어져 있습니다. 클레임은 다음 세 가지 유형으로 나눌 수 있습니다:

  1. 등록된 클레임 (Registered Claims)
    • 표준화된 클레임으로 JWT에 대한 정보를 담는 데 사용됩니다.
    • iss: 토큰 발급자 (issuer)
    • sub: 토큰 제목 (subject)
    • aud: 토큰 대상자 (audience)
    • exp: 토큰의 만료 시간 (expiration)
    • nbf: 토큰의 활성 날짜 (Not Before)
    • iat: 토큰이 발급된 시간 (issued at)
    • jti: JWT의 고유 식별자 (JWT ID)
  2. 공개 클레임 (Public Claims)
    • 공개되어도 상관없는 클레임으로, 충돌을 방지하기 위해 URI로 이름을 짓습니다.
    • 예: "https://example.com/is_admin": true
  3. 비공개 클레임 (Private Claims)
    • 클라이언트와 서버 간에 공유되는 클레임으로, 공개되어서는 안 됩니다.
    • 예: "email": "user@example.com"

예시:

{
  "iss": "ajufresh@gmail.com",
  "iat": 1622370878,
  "exp": 1622372678,
  "https://gunwoo.com/jwt_claims/is_admin": true,
  "email": "ajufresh@gmail.com",
  "hello": "안녕하세요!"
}

3. 서명 (Signature)

서명은 해당 토큰이 조작되거나 변경되지 않았음을 확인하는 용도로 사용됩니다. 서명은 헤더와 내용의 인코딩 값을 합친 후에 비밀 키를 사용하여 해시 값을 생성합니다.

  • 생성 방법: HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

JWT를 이용해 인증을 하려면 HTTP 요청 헤더 중에 Authorization 키값에 Bearer + JWT 토큰값을 넣어 보내야 한다.

 

 

토큰 유효기간

문제 상황

액세스 토큰이 노출되면, 탈취한 사람이 그 토큰으로 서버에 인증된 요청을 할 수 있습니다. 이를 방지하기 위해 액세스 토큰의 유효기간을 짧게 설정할 수 있지만, 사용자는 자주 토큰을 갱신해야 하므로 불편함이 발생합니다.

리프레시 토큰의 역할

리프레시 토큰은 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 발급받기 위해 사용되는 별개의 토큰입니다. 액세스 토큰의 유효 기간을 짧게 설정하고, 리프레시 토큰의 유효 기간을 길게 설정하여 보안을 강화할 수 있습니다.

토큰 발급 및 갱신 과정 요약

  1. 인증 요청
    • 클라이언트: 서버에 인증 요청을 보냅니다.
    • 서버: 인증 정보가 유효한지 확인한 뒤, 액세스 토큰과 리프레시 토큰을 생성하여 클라이언트에게 전달합니다.
    • 클라이언트: 전달받은 토큰을 저장합니다. (리프레시 토큰은 DB에도 저장됩니다)
  2. API 요청
    • 클라이언트: 인증이 필요한 API를 호출할 때, 저장된 액세스 토큰과 함께 요청을 보냅니다.
    • 서버: 액세스 토큰의 유효성을 검사하여 유효하면 요청을 처리합니다.
  3. 액세스 토큰 만료
    • 클라이언트: 액세스 토큰이 만료된 후 서버에 API 요청을 보냅니다.
    • 서버: 토큰이 만료되었음을 응답합니다.
  4. 리프레시 토큰을 통한 액세스 토큰 갱신
    • 클라이언트: 저장된 리프레시 토큰과 함께 새로운 액세스 토큰 발급 요청을 보냅니다.
    • 서버: 리프레시 토큰의 유효성을 검사하고, 유효하면 새로운 액세스 토큰을 생성하여 응답합니다.
    • 클라이언트: 새로운 액세스 토큰을 저장하고, 이를 사용하여 다시 API를 요청합니다.

정리

  1. 클라이언트가 서버에게 인증 요청  서버는 인증 정보를 확인 후 액세스 토큰과 리프레시 토큰을 발급
  2. 클라이언트는 토큰을 저장  리프레시 토큰은 서버 DB에도 저장
  3. 클라이언트가 API 요청 시 액세스 토큰을 사용  서버는 액세스 토큰의 유효성을 검사
  4. 액세스 토큰 만료 시 서버는 에러 응답  클라이언트는 리프레시 토큰으로 새로운 액세스 토큰 요청
  5. 서버는 리프레시 토큰의 유효성을 확인 후 새로운 액세스 토큰 발급  클라이언트는 새로운 액세스 토큰으로 API 재요청

 

TokenProvider 작성

@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);
    }

    //JWT 토큰 생성 메서드
    private String makeToken(Date expiry, User user) {
        Date now = new Date();


        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE) //헤더 typ : JWT
                //내용 iss : ajufresh@gmail.com(propertise 파일에서 설정한 값)
                .setIssuer(jwtProperties.getIssuer())
                .setIssuedAt(now) //내용 iat : 현재 시간
                .setExpiration(expiry)//내용 exp : expiry 멤버 변숫값
                .setSubject(user.getEmail())// 내용 sub : 유저의 이메일
                .claim("id", user.getId())//클레임 id : 유저 ID
                //서명 : 비밀값과 함께 해시값을 HS256 방식으로 암호화
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }

    //JWT 토큰 유효성 검증 메서드
    public  boolean validToken(String token){
        try{
            Jwts.parser()
                    .setSigningKey(jwtProperties.getSecretKey()) // 비밀값으로 복호화
                    .parseClaimsJws(token);
            return true;
        }catch (Exception e) {// 복호화 과정에서 에러가 나면 유효하지 않은 토큰
        return false;
        }
    }

    //토큰 기반으로 인증 정보를 가져오는 메서드
    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);
    }

    //토큰 기반으로 유저 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. JWT 토큰 생성 메서드

클래스 및 메서드:

public String generateToken(User user, Duration expiredAt){
    Date now = new Date();
    return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
}

설명:

  • generateToken 메서드는 사용자의 정보와 만료 기간을 인자로 받아 JWT 토큰을 생성합니다.
  • 현재 시간(now)을 기준으로 만료 시간을 계산하여 makeToken 메서드를 호출합니다.
private String makeToken(Date expiry, User user) {
    Date now = new Date();

    return Jwts.builder()
            .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // 헤더에 typ을 JWT로 설정
            .setIssuer(jwtProperties.getIssuer()) // 발급자 설정
            .setIssuedAt(now) // 발급 시간 설정
            .setExpiration(expiry) // 만료 시간 설정
            .setSubject(user.getEmail()) // 토큰 제목 설정 (사용자 이메일)
            .claim("id", user.getId()) // 클레임에 사용자 ID 추가
            .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey()) // 비밀키로 서명
            .compact();
}

설명:

  • makeToken 메서드는 JWT 토큰을 생성합니다.
  • 헤더에 typ(JWT 타입)을 설정하고, 내용에는 iss(발급자), iat(발급 시간), exp(만료 시간), sub(사용자 이메일)을 설정합니다.
  • 클레임에 id(사용자 ID)를 포함시키고, 비밀키로 HS256 방식으로 서명합니다.

2. JWT 토큰 유효성 검증 메서드

클래스 및 메서드:

public boolean validToken(String token){
    try {
        Jwts.parser()
                .setSigningKey(jwtProperties.getSecretKey()) // 비밀값으로 복호화
                .parseClaimsJws(token);
        return true;
    } catch (Exception e) {
        // 복호화 과정에서 에러가 나면 유효하지 않은 토큰
        return false;
    }
}

설명:

  • validToken 메서드는 주어진 토큰을 검증합니다.
  • 비밀키를 사용하여 토큰을 복호화하고, 복호화 과정에서 에러가 발생하지 않으면 true를 반환합니다. 에러가 발생하면 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
    );
}

설명:

  • getAuthentication 메서드는 주어진 토큰을 기반으로 인증 정보를 생성합니다.
  • getClaims 메서드를 사용하여 토큰의 클레임을 가져오고, 이를 바탕으로 UsernamePasswordAuthenticationToken을 생성합니다.
  • 스프링 시큐리티의 User 클래스를 사용하여 사용자 정보를 설정합니다.

4. 사용자 ID 반환 메서드

클래스 및 메서드:

 
public Long getUserId(String token) {
    Claims claims = getClaims(token);
    return claims.get("id", Long.class);
}

설명:

  • getUserId 메서드는 주어진 토큰에서 사용자 ID를 추출합니다.
  • getClaims 메서드를 사용하여 토큰의 클레임을 가져오고, 클레임에서 id 값을 추출하여 반환합니다.

5. 클레임 정보 반환 메서드

클래스 및 메서드:

 
private Claims getClaims(String token) {
    return Jwts.parser()
            .setSigningKey(jwtProperties.getSecretKey())
            .parseClaimsJws(token)
            .getBody();
}

설명:

  • getClaims 메서드는 주어진 토큰의 클레임 정보를 반환합니다.
  • 비밀키를 사용하여 토큰을 복호화하고, 클레임 정보를 추출합니다.

 

 

 

 

 

리프세시 토큰 도메인 구현

@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;
    }
}

 

TokenAuthenticationFillter 작성

@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;
    }
}

코드 설명

클래스 정의

  • TokenAuthenticationFilter는 OncePerRequestFilter를 상속받아 매 요청마다 한 번씩 필터링을 수행합니다.
  • @RequiredArgsConstructor를 사용하여 TokenProvider를 주입받습니다.

상수 정의

  • HEADER_AUTHORIZATION: 요청 헤더에서 인증 정보를 가져올 때 사용되는 키입니다.
  • TOKEN_PREFIX: 토큰의 접두사로 사용되는 문자열입니다. 일반적으로 "Bearer "로 시작합니다.

doFilterInternal 메서드

  • 인자:
    • HttpServletRequest request: 클라이언트의 요청을 나타냅니다.
    • HttpServletResponse response: 서버의 응답을 나타냅니다.
    • FilterChain filterChain: 다른 필터와의 체인 연결을 나타냅니다.
  • 역할:
    • 요청 헤더에서 인증 정보를 가져옵니다.
    • getAccessToken 메서드를 통해 토큰을 추출합니다.
    • 토큰이 유효한지 검증하고, 유효한 경우 TokenProvider를 사용하여 인증 객체를 생성합니다.
    • SecurityContextHolder에 인증 객체를 저장합니다.
    • 필터 체인을 통해 다음 필터나 서비스 로직으로 요청을 전달합니다.

getAccessToken 메서드

  • 인자:
    • String authorizationHeader: 요청 헤더에서 가져온 인증 정보입니다.
  • 역할:
    • 인증 정보가 Bearer 로 시작하는 경우, 접두사를 제거하고 실제 토큰을 반환합니다.
    • 그렇지 않은 경우 null을 반환합니다.

동작 흐름 정리

  1. 요청 수신: 클라이언트가 요청을 보내면 doFilterInternal 메서드가 호출됩니다.
  2. 토큰 추출: 요청 헤더에서 Authorization 값을 가져와 getAccessToken 메서드를 통해 토큰을 추출합니다.
  3. 토큰 검증: 추출한 토큰이 유효한지 TokenProvider의 validToken 메서드로 검증합니다.
  4. 인증 정보 저장: 유효한 토큰인 경우, TokenProvider의 getAuthentication 메서드를 사용하여 인증 정보를 생성하고, SecurityContextHolder에 저장합니다.
  5. 서비스 로직 실행: 필터 체인을 통해 다음 필터 또는 서비스 로직으로 요청을 전달합니다.
  6. 응답 반환: 서비스 로직이 실행된 후 클라이언트에게 응답을 반환합니다.

이 필터를 통해 모든 요청에 대해 JWT 토큰을 검증하고, 유효한 토큰의 경우 시큐리티 컨텍스트에 인증 정보를 설정하여 인증된 사용자만이 서비스 로직을 실행할 수 있도록 합니다.

 

TokenService 작성

 

@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));
    }
}
  • createNewAccessToken 메서드는 리프레시 토큰의 유효성을 검사합니다. 유효하지 않으면 예외를 발생시킵니다.
  • 유효한 리프레시 토큰인 경우, RefreshTokenService를 통해 사용자 ID를 조회하고, UserService를 통해 사용자 정보를 조회합니다.
  • TokenProvider를 사용하여 새로운 액세스 토큰을 생성합니다.

 

TokenRefreshService 작성

 

@Service
@RequiredArgsConstructor
public class RefreshTokenService {
    private final RefreshTokenRepository refreshTokenRepository;

    public RefreshToken findByRefreshToken(String refreshToken){
        return refreshTokenRepository.findByRefreshToken(refreshToken)
                .orElseThrow(()->new IllegalArgumentException("Unexpected token"));
    }
}
  • findByRefreshToken 메서드는 리프레시 토큰을 데이터베이스에서 조회하고, 찾지 못하면 예외를 발생시킵니다.

 

TokenApiController 작성

 

@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));
    }
}
  • createNewAccessToken 메서드는 클라이언트로부터 리프레시 토큰을 받아 새로운 액세스 토큰을 생성합니다.
  • 생성된 액세스 토큰을 CreateAccessTokenResponse 객체로 감싸서 HTTP 상태 코드 201 Created와 함께 응답합니다.

 

클래스 간의 관계

  1. TokenApiController: 클라이언트로부터 리프레시 토큰을 받아 TokenService에 전달합니다.
  2. TokenService: 리프레시 토큰의 유효성을 검증하고, 유효한 경우 TokenProvider를 사용해 새로운 액세스 토큰을 생성합니다. 이를 위해 RefreshTokenService와 UserService를 사용합니다.
  3. RefreshTokenService: 리프레시 토큰을 데이터베이스에서 조회하여 유효성을 검증합니다.
  4. TokenProvider: JWT 토큰을 생성하고 유효성을 검증하는 역할을 합니다.
  5. UserService: 사용자 정보를 조회합니다.

종합 동작 흐름

  1. 클라이언트가 /api/token 엔드포인트로 POST 요청을 보냅니다. 요청 본문에는 CreateAccessTokenRequest 객체가 포함됩니다.
  2. TokenApiController는 요청을 받아 TokenService의 createNewAccessToken 메서드를 호출합니다.
  3. TokenService는 리프레시 토큰의 유효성을 TokenProvider를 통해 검증합니다.
  4. 유효한 토큰인 경우, RefreshTokenService를 통해 리프레시 토큰에 해당하는 사용자 ID를 조회합니다.
  5. 사용자 ID를 통해 UserService에서 사용자 정보를 조회합니다.
  6. TokenProvider를 사용해 해당 사용자에 대한 새로운 액세스 토큰을 생성합니다.
  7. 생성된 새로운 액세스 토큰을 CreateAccessTokenResponse 객체로 감싸서 클라이언트에 응답합니다.

이 구현을 통해 리프레시 토큰을 사용해 안전하게 새로운 액세스 토큰을 생성하고, 이를 클라이언트에 제공할 수 있습니다.

'Spring공부' 카테고리의 다른 글

OAuth2 로 로그인/로그아웃 구현하기  (0) 2024.05.28
240524_TIL  (0) 2024.05.24
블로그의 인증, 인가 쿠키&세션  (0) 2024.05.23
240520_TIL  (0) 2024.05.20
스프링 부트3로 블로그 만들기  (0) 2024.05.20