본문 바로가기

Spring

스프링부트 / JWT 방식으로 로그인 구현하기

Token 방식으로 로그인 구현하기

🔸 Session/Cookie 방식 vs Token 방식?

사용자 인증 방식에는 크게 Session 방식토큰 방식이 있습니다.

Session / Cookie 방식

Session 방식은 사용자가 로그인 요청을 보내면 사용자를 확인 후 Session ID를 발급하고 그 발급한 ID를 이용하여
다른 요청과 응답을 처리하는 방식입니다.


하지만 이 경우, 프로그램이 커져서 관리하는 Session 이 늘어날 경우 별도로 세션 저장소를 관리해주어야 하는 번거로움이 있습니다.

Token 방식

그에 반해 Token 방식은 저장소의 필요 없이 로그인 시 토큰을 발급하고, 데이터 요청 시에 발급받은 토큰을 헤더를 통해 전달하여 응답을 받는 방식으로 진행됩니다.


이번 포스팅에서는 스프링 부트에서 Token 방식을 이용한 로그인이 어떻게 이루어지는지, 발급한 토큰을 어떻게 헤더를 통해 전달하여 처리를 하는지 알아보려고 합니다.


🔸 Token 방식으로 로그인하기 전체적인 흐름🔭

사실 Token 방식으로 로그인하는 것은 Session 방식을 이용하는 것보다 훨씬 복잡합니다.(적어도 저는 그렇게 느꼈습니다😭)
그래서 본격적으로 구현하기 앞서 큰 흐름을 설명하고 가겠습니다.

1. 로그인으로 토큰(JWT) 발급

첫 번째로 로그인을 통해 토큰을 발급받습니다.
여기서는 token을 만들 때 jjwt 라이브러리를 이용하여 JWT(Json Web Token) 방식으로 생성을 합니다.

JWT?

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

위 코드는 JWT의 예시입니다.


JWT는 세 부분으로 나누어져 필요한 모든 정보를 자체적으로 지니고 있다는 특징이 있습니다.
따라서 별도의 디비 접근 없이 해당 토큰의 유효성 검증 가능합니다.
https://jwt.io/ 에 접속하면 토큰 안의 정보들을 볼 수 있습니다.

2. 발급받은 토큰을 웹 스토리지에 저장

프런트엔드에서 로그인 요청을 보내면 백엔드에서 토큰을 발급하여 응답으로 토큰을 보내줍니다.
그리고 그 토큰을 웹 스토리지에 저장을 합니다.

Web Storage?

 


위의 그림은 웹브라우저(크롬 기준)의 개발자 도구에서 Application항목의 모습입니다.
Storage는 key-value의 형태로 데이터를 저장하는 저장소로, 왼쪽 사이드 메뉴를 보면 Storage 부분이 있고 하위 항목으로는 Local Storage, Session Storage, Cookies가 있습니다.
이 중에 이번 글에서는 Local Storage를 선택하여 저장을 합니다.
각 항목별로 정보의 휘발성, 보안 등 특징이 있으며 개발자의 의도에 부합하는 특징을 가진 선택지를 고르면 되겠습니다.

3. 저장된 토큰을 가지고 나의 정보 요청

이제 로그인이 되었고, 지금의 상태는 Local Storage에 토큰이 저장되어 있는 상태입니다.
이제 프런트엔드에서 토큰을 헤더에 담아 백엔드로 요청을 보내고(예시로 내 정보를 요청합니다), 백엔드에서는 토큰 안에 담긴 정보를 확인하여 그에 해당하는 응답을 내려줍니다.
여기서 주목해야 할 점은 백엔드에서 토큰 안에 담긴 정보를 확인하는 것입니다.
이 단계에서 Interceptor의 개념이 등장합니다.

Interceptor?

 


인터셉터는 요청이 Controller로 가기 전에 요청을 가로채 작업을 처리할 수 있습니다.
여기서는 요청 안에 토큰이 있는지 확인하고, 토큰 안에 있는 내용을 디코딩하여 요청안에 다시 넣어주는 작업을 합니다.
Interceptor와 대조되는 Filter라는 게 있는데, 자세한 설명은 생략하고 여기서는 Interceptor를 사용하도록 하겠습니다.

4. 요청한 정보에 대한 응답📞

이제 Controller에서 요청을 받아 요구하는 데이터를 내려줍니다.

🔸 구현!💻

이제 본격적으로 구현을 해봅시다.

1. 로그인으로 토큰(JWT) 발급

1.1 프런트엔드에서 로그인 요청

public class User {
    @Id
    private Long id;
    private String name;
    private String pwd;
      ...
}

Id = 1, name = "홍길동", pwd = "1234"의 유저가 현재 가입되어있는 상태라고 가정합니다.

fetch("/login", {
    method: 'post',
    headers: {
        'content-type': 'application/json'
    },
    body : JSON.stringify({
        name : $name.value,
        pwd : $pwd.value
    })
}).then(res => res.json())
    .then(token => {
        localStorage.setItem("jwt",token.accessToken)
        alert("로그인 되었습니다");
    });

fetch API 로 사용자가 입력한 홍길동/1234를 보내면 백엔드에서 생성한 토큰을 응답받아

localStorage.setItem("jwt",token.accessToken)

명령의 통해 localStorage에 저장하는 구조입니다.

로그인 요청!⌨️🙌

1.2 Controller

이제 controller를 살펴봅시다. 아래의 controller는 모두 @RestController입니다.

@PostMapping("/login")
    public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest loginRequest) {
        String token = userService.createToken(loginRequest);
        return ResponseEntity.ok().body(new TokenResponse(token, "bearer"));
    }

LoginRequest라는name과 pwd를 필드 변수로 가지고 있는 객체를 만들어 프런트에서 보낸 정보를 받아,
-> 일련의 과정(UserService.createToken)을 거쳐 토큰을 생성하고
-> 생성된 토큰을 TokenResponse 객체로 감싸 프런트로 보내줍니다.
TokenResponse

public class TokenResponse {
    private String accessToken;
    private String tokenType;
      ...

1.3 createToken

UserService.createToken

public String createToken(LoginRequest loginRequest) {
    User user = userRepository.findByName(loginRequest.getName())
            .orElseThrow(IllegalArgumentException::new);
      //비밀번호 확인 등의 유효성 검사 진행
    return jwtTokenProvider.createToken(user.getName());
}

여기서 핵심은 jwtTokenProvider.createToken입니다.
jwt방식을 이용하여 토큰을 생성하려면 라이브러리를 추가해주어야 합니다.
build.gradle dependencies에 추가

dependencies {
    ...
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    }

JwtTokenProvider

@Component
public class JwtTokenProvider {
    private String secretKey;
    private long validityInMilliseconds;

    public JwtTokenProvider(@Value("${security.jwt.token.secret-key}") String secretKey, @Value("${security.jwt.token.expire-length}") long validityInMilliseconds) {
        this.secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
        this.validityInMilliseconds = validityInMilliseconds;
    }

        //토큰생성
    public String createToken(String subject) {
        Claims claims = Jwts.claims().setSubject(subject);

        Date now = new Date();

        Date validity = new Date(now.getTime()
                + validityInMilliseconds);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

        //토큰에서 값 추출
    public String getSubject(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

        //유효한 토큰인지 확인
    public boolean validateToken(String token) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            if (claims.getBody().getExpiration().before(new Date())) {
                return false;
            }
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
}

JwtTokenProvider 클래스의 모습입니다. 너무너무 복잡합니다.😨
하나하나 이해하기에는 복잡하기 때문에 추후 시간을 두고 하나씩 공부해보기로 하고 일단은 위와 같이 구현합니다.
여기서 ${security.jwt.token.secret-key} 와 ${security.jwt.token.expire-length} 를 위해서 application.properties에 다음과 같이 추가해줍니다.
application.properties

...
security.jwt.token.secret-key= secretsecretsecretsecretsecret
security.jwt.token.expire-length= 3600000

그럼 이제 로그인 후 토큰을 생성하여 LocalStorage에 저장하는 단계까지 구현하였습니다.
한번 확인해 보겠습니다.

1.4 Storage 에 토큰 저장

 

로그인 전
로그인 후


LocalStorage에 jwt라는 이름으로 무언가 추가된 것을 확인하였습니다.

2. 토큰으로 내 정보 요청

2.1 프런트에서 내정보 요청

이번에도 fetch API를 이용하여 내 정보를 요청해봅시다.

    fetch("/info",{
        method: 'get',
        headers: {
            'content-type': 'application/json',
            'Authorization': 'Bearer ' + localStorage.getItem("jwt"),
        }
    }).then(res => res.json())
        .then(json => alert("이름 : " + json.name+", 비밀번호 : " + json.pwd))

위의 코드는 /info라는 주소로 요청을 보내고 있고, Controller의 구현의 다음과 같습니다.

@GetMapping("/info")
public ResponseEntity<UserResponse> getUserFromToken(HttpServletRequest request) {
    String name = (String) request.getAttribute("name");
    User user = userService.findByName((String) request.getAttribute("name"));
    return ResponseEntity.ok().body(UserResponse.of(user));
}

여기서 주목할 부분은 fetch API의 headers 부분입니다.
헤더에 'Authorization': 'Bearer ' + localStorage.getItem("jwt") 으로 토큰을 전달해 줍니다.

2.2 Interceptor

앞서 전체적인 흐름을 다룰 때 언급한 것처럼 요청이 controller에 도달하기 전에 Interceoptor로 요청을 가로채 header에 포함된 토큰의 내용을 디코딩하여 그 내용을 다시 요청으로 담아 controller 전달해 주어야 합니다.
그러기 위해서는 어떤 요청에 대해서 인터셉터 할지 interceptor를 등록을 해놓아야 합니다.
이때 WebMvcConfigurer를 이용합니다.
WebMvcConfig

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    private final BearerAuthInterceptor bearerAuthInterceptor;

    public WebMvcConfig(BearerAuthInterceptor bearerAuthInterceptor) {
        this.bearerAuthInterceptor = bearerAuthInterceptor;
    }

    public void addInterceptors(InterceptorRegistry registry){
        System.out.println(">>> 인터셉터 등록");
        registry.addInterceptor(bearerAuthInterceptor).addPathPatterns("/info");
    }
}

WebMvcConfig.aaddInterceptors를 보면 '/info'라는 패턴으로 들어오는 요청에 대해서 bearerAuthInterceptor를 등록해주었습니다. 이렇게 하면 애플리케이션이 실행될 때 인터셉터를 등록하고 그 주소로 들어오는 요청을 기다리는 상태가 됩니다.

어플리케이션 실행 시 '>>> 인터셉터 등록' 출력


이제

요청이 지나다니는 길에 인터셉터를 설치

인터셉터를 등록하였으니 구현해 봅시다.
HandlerInterceptor를 implements 하여 구현합니다.
bearerAuthInterceptor

@Component
public class BearerAuthInterceptor implements HandlerInterceptor {
    private AuthorizationExtractor authExtractor;
    private JwtTokenProvider jwtTokenProvider;

    public BearerAuthInterceptor(AuthorizationExtractor authExtractor, JwtTokenProvider jwtTokenProvider) {
        this.authExtractor = authExtractor;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) {
        System.out.println(">>> interceptor.preHandle 호출");
        String token = authExtractor.extract(request, "Bearer");
        if (StringUtils.isEmpty(token)) {
            return true;
        }

        if (!jwtTokenProvider.validateToken(token)) {
            throw new IllegalArgumentException("유효하지 않은 토큰");
        }

        String name = jwtTokenProvider.getSubject(token);
        request.setAttribute("name", name);
        return true;
    }
}

info라는 주소로 요청을 보내서 interceptor 가 호출되면 interceptor는 preHandle 메서드를 호출합니다.
preHandle 메서드는 크게 3가지 동작을 합니다.

  • request로부터 authExtractor.extract로 토큰을 추출😨
  • jwtTokenProvider.getSubject로 토큰을 디코딩 - 위의 JwtTokenProvider 참고😃
  • request.setAttribute로 요청에 디코딩한 값을 세팅😃

헤더로부터 토큰을 추출하는 방법을 모르니 한번 살펴봅시다.

2.3 헤더에서 토큰 추출하기

AuthorizationExtractor

@Component
public class AuthorizationExtractor {
    public static final String AUTHORIZATION = "Authorization";
    public static final String ACCESS_TOKEN_TYPE = AuthorizationExtractor.class.getSimpleName() + ".ACCESS_TOKEN_TYPE";

    public String extract(HttpServletRequest request, String type) {
        Enumeration<String> headers = request.getHeaders(AUTHORIZATION);
        while (headers.hasMoreElements()) {
            String value = headers.nextElement();
            if (value.toLowerCase().startsWith(type.toLowerCase())) {
                return value.substring(type.length()).trim();
            }
        }

        return Strings.EMPTY;
    }
}

우리는 프런트에서 요청을 보낼 때 토큰을 headers의 'Authorization'이라는 Key로 담아 보냈습니다.
위의 코드를 간단히 설명하면 request의 헤더 중에 'Authorization' 항목의 값을 가져와서 그 안에 토큰 타입을 제외한 토큰 자체를 가져오는 로직입니다.

2.4 Controller

이제 모든 준비는 끝났습니다.(길고도 험했던...😭)
헤더에 토큰을 담아 요청을 보냈고, 우리는 요청을 가로채 토큰을 확인한 다음 요청에 그 토큰이 의미하는 값을 담아주었습니다.
controller에서 그 값을 받아 응답을 내려주는 단계만 남았습니다.

@GetMapping("/info")
public ResponseEntity<UserResponse> getUserFromToken(HttpServletRequest request) {
    String name = (String) request.getAttribute("name");
    User user = userService.findByName(name);
    return ResponseEntity.ok().body(UserResponse.of(user));
}

BearerAuthInterceptor.preHandle 메서드를 보면 HttpServletRequest라는 request에 name을 담아주고 있습니다. 따라서 controller에서 메서드 인자에 동일하게 'HttpServletRequest request'를 선언하여 getAttribute로 name의 값을 가져옵니다.
그리고 이름으로 유저를 찾아주면 끗!👏

2.5 결과 확인

 


java9 이상의 버전에서는 java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter 에러가 발생할 수 있습니다.
이때는 build.gradle 에

dependencies {
	...
	compile "javax.xml.bind:jaxb-api" 
    }

의존성 추가를 해주시면 해결됩니다.

반응형