JAVA : SpringBoot2 & Thymeleaf 에서 JWT 이용해서 로그인 하기

SpringBoot2 & Thymeleaf 로 구성된 전통적인(?) 환경에서 JWT 를 이용한 로그인 처리 샘플

JWT 방식으로 하면 SSO 처리를 쉽게 할 수 있을 것 같아서 나중에 써먹을려고 샘플을 일단 만들어 보았다.

전체소스는 여기로 https://github.com/stove99/springboot-thymeleaf-jwt

jwt maven dependency

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

JWTService.java

jwt 토큰을 맨들고 토큰이 쪽바른지 아닌지 체크하는 서비스

package io.github.stove99.jwt_sample.service;

import java.security.Key;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

@Service
public class JWTService {
    // application.properties 에 secret 설정, 대충 원하는 문자열 10~20글자정도?
    @Value("${site.jwt.secret}")
    private String secret;

    /**
     * body 가 들어간 토큰 생성
     *
     * @param body
     * @param expired 토근 만료 시간
     * @return
     */
    public String token(Map<String, Object> body, Optional<LocalDateTime> expired) {
        byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(secret);

        Key key = new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS512.getJcaName());

        JwtBuilder builder = Jwts.builder()
                                .setClaims(body)
                                .setExpiration(Timestamp.valueOf(LocalDateTime.now().plusDays(1)))
                                .signWith(SignatureAlgorithm.HS512, key);

        // 만료시간을 설정할 경우 expir 설정
        expired.ifPresent(exp -> {
            builder.setExpiration(Timestamp.valueOf(exp));
        });

        return builder.compact();
    }

    /**
     * 기본 만료시간 : 하루 30분 : LocalDateTime.now().plusMinutes(30) 1시간 :
     * LocalDateTime.now().plusHours(1)
     *
     * @param body
     * @return
     */
    public String token(Map<String, Object> body) {
        return token(body, Optional.of(LocalDateTime.now().plusDays(1)));
    }

    /**
     * 토큰 검증후 저장된 값 복원
     */
    public Map<String, Object> verify(String token) {
        Claims claims = Jwts.parser()
                            .setSigningKey(DatatypeConverter.parseBase64Binary(secret))
                            .parseClaimsJws(token)
                            .getBody();

        return new HashMap<>(claims);
    }
}

LoginCheck.java

JWTService 를 써먹도록 인터셉터를 하나 살짝 추가해 준다.

jwt 토큰 체크 후 쪽바른 토큰이 아니라면 로그인 화면으로 이동시킴

package io.github.stove99.jwt_sample.login;

import java.util.Arrays;
import java.util.Map;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.ModelAndViewDefiningException;

import io.github.stove99.jwt_sample.service.JWTService;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import lombok.extern.slf4j.Slf4j;

/**
 * 로그인 여부 체크 인터셉터
 */
@Component
@Slf4j
public class LoginCheck implements HandlerInterceptor {
    public static final String COOKIE_NAME = "login_token";

    @Autowired
    private JWTService jwtService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws ModelAndViewDefiningException {

        String token = Arrays.stream(request.getCookies())
                            .filter(cookie -> cookie.getName().equals(LoginCheck.COOKIE_NAME))
                            .findFirst().map(Cookie::getValue)
                            .orElse("dummy");

        log.info("token : {}", token);

        try {
            Map<String, Object> info = jwtService.verify(token);

            // View 에서 session.id 처럼 로그인 정보 쉽게 가져다 쓸수 있도록 request 에 verify 한 사용자 정보 설정
            User user = User.builder()
                                .id((String) info.get("id"))
                                .name((String) info.get("name"))
                                .build();

            // view 에서 login.id 로 접근가능
            request.setAttribute("login", user);
        } catch (ExpiredJwtException ex) {
            log.error("토근이 만료됨");

            ModelAndView mav = new ModelAndView("login");
            mav.addObject("return_url", request.getRequestURI());

            throw new ModelAndViewDefiningException(mav);
        } catch (JwtException ex) {
            log.error("비정상 토큰");

            ModelAndView mav = new ModelAndView("login");

            throw new ModelAndViewDefiningException(mav);
        }

        return true;
    }
}

로그인 정보를 위한 아규먼트 리졸버

로그인 정보를 가져올려면 Cookie 에서 토큰을 뒤져서 verify 하는 귀찮은 짖을 계속해야 된다.

@LoginUser String id 요딴식으로 어노테이션을 써서 쉽게 로그인한 사용자의 정보를 가져올 수 있도록 아규먼트 리졸버를 하나 맹들어 본다.

LoginUserResolver.java

package io.github.stove99.jwt_sample.login;

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import io.github.stove99.jwt_sample.service.JWTService;

/**
 * 로그인 유저 정보 쉽게 가져오기 위한 Argument Resolver
 */
@Component
public class LoginUserResolver implements HandlerMethodArgumentResolver {
    @Autowired
    private JWTService jwtService;

    @Override
    public boolean supportsParameter(MethodParameter param) {
        return param.hasParameterAnnotation(LoginUser.class);
    }

    @Override
    public Object resolveArgument(MethodParameter param, ModelAndViewContainer mvc, NativeWebRequest nreq,
            WebDataBinderFactory dbf) throws Exception {
        final Map<String, Object> resolved = new HashMap<>();

        HttpServletRequest req = (HttpServletRequest) nreq.getNativeRequest();

        // 쿠키에 토큰이 있는 경우 꺼내서 verify 후 로그인 정보 리턴
        Arrays.stream(req.getCookies())
                .filter(cookie -> cookie.getName().equals(LoginCheck.COOKIE_NAME))
                .map(Cookie::getValue).findFirst().ifPresent(token -> {
                    Map<String, Object> info = jwtService.verify(token);

                    // @LoginUser String id, @LoginUser String name
                    if (param.getParameterType().isAssignableFrom(String.class)) {
                        resolved.put("resolved", info.get(param.getParameterName()));
                    }
                    // @LoginUser User user
                    else if (param.getParameterType().isAssignableFrom(User.class)) {
                        User user = User.builder()
                                        .id((String) info.get("id"))
                                        .name((String) info.get("name"))
                                        .build();

                        resolved.put("resolved", user);
                    }
                });

        return resolved.get("resolved");
    }
}

LoginUser.java

package io.github.stove99.jwt_sample.login;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

User.java

package io.github.stove99.jwt_sample.login;

import lombok.Builder;
import lombok.Data;

@Builder
@Data
public class User {

    private String id;
    private String name;
}

맨든 인터셉터와 아규먼트 리졸버 등록하기

적당히 Config 를 위한 클래스를 하나 맹글어서 설정해 준다.

DemoConfig.java

package io.github.stove99.jwt_sample;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import io.github.stove99.jwt_sample.login.LoginCheck;
import io.github.stove99.jwt_sample.login.LoginUserResolver;

@Configuration
public class DemoConfig implements WebMvcConfigurer {

    @Autowired
    private LoginUserResolver loginUserResolver;

    @Autowired
    LoginCheck loginCheck;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginCheck).addPathPatterns("", "/**")
                    .excludePathPatterns("/login", "/resources/**");
    }

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(loginUserResolver);
    }
}

컨트롤러

package io.github.stove99.jwt_sample.controller;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import io.github.stove99.jwt_sample.login.LoginCheck;
import io.github.stove99.jwt_sample.login.LoginUser;
import io.github.stove99.jwt_sample.service.JWTService;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Controller
public class SampleController {
    @Autowired
    private JWTService jwtService;

    @GetMapping("/")
    public String rootPage() {
        return "redirect:/main";
    }

    @GetMapping("login")
    public void loginPage() {
    }

    @PostMapping("login")
    public String login(@RequestParam String id, @RequestParam String pwd, HttpServletResponse res) {
        // 로그인 로직

        // 로그인 성공시 쿠키에 token 저장
        Map<String, Object> user = new HashMap<>();
        user.put("id", id);
        user.put("name", "홍길동");

        // 30분후 만료되는 jwt 만들어서 쿠키에 저장
        Cookie cookie = new Cookie(
                            LoginCheck.COOKIE_NAME,
                            jwtService.token(user, Optional.of(LocalDateTime.now().plusMinutes(30)))
                        );

        cookie.setPath("/");
        cookie.setMaxAge(Integer.MAX_VALUE);

        res.addCookie(cookie);

        return "redirect:/main";
    }

    /**
     * 로그아웃 처리 : 쿠키에서 jwt 삭제
     */
    @GetMapping
    public String logout(HttpServletResponse res) {
        Cookie cookie = new Cookie(LoginCheck.COOKIE_NAME, "");
        cookie.setPath("/");
        cookie.setMaxAge(0);

        res.addCookie(cookie);

        return "redirect:/login";
    }

    @GetMapping("main")
    public void mainPage(Model model, @LoginUser String id) {
        log.info("로그인 아이디 : {}", id);
    }
}

View

main.html

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta http-equiv="X-UA-Compatible" content="ie=edge" />
        <title>Document</title>
    </head>
    <body>
        <p>로그인 후 접속가능한 페이지</p>

        ID : <span th:text="${login.id}">로그인 아이디</span> NAME : <span th:text="${login.name}">로그인 사용자</span>

        <p><a href="/logout">로그아웃</a></p>
    </body>
</html>