not-a-gardener 개발기 3. JWT와 로그인

메밀·2022년 12월 5일
0

not-a-gardener 개발기

목록 보기
3/6

1. 세션 기반 인증과 토큰 기반 인증

1) 쿠키, 세션, JWT

HTTP 프로토콜은 stateless. 서버로 가는 모든 리퀘스트가 이전 리퀘스트와는 독립적으로 이루어진다. 즉, 요청이 끝나면 서버는 우리가 누군지 잊어버린다. Cookie, Session, JWT는 모두 비연결성인 네트워크 서버 특징을 연결성으로 사용하기 위한 방법이다.


2) 비교

쿠키토큰
서버가 유저의 브라우저에 데이터를 넣는 방법이상하게 생긴 String
현재 로그인한 유저의 모든 세션ID를 DB에 저장.서버는 db에 뭔가를 생성하지 않음.
이 세션ID를 쿠키에 담아 전송유저 ID로 ‘signiture’를 만들고, 해당 ‘사인된 정보’를 String 형태로 보낸다.
요청이 들어올 때마다 서버는 쿠키의 세션 ID를 사용해 세션DB에서 유저 확인
즉, 유저가 늘어남에 따라 DB 리소스가 더 필요하다.
토큰을 받으면 해당 사인이 유효한지 체크하고(조작 여부, 기간 만료 여부), 유효하다면 서버는 우리를 유저로 인정.
공간 제한 있음공간 제한 없음
특정 유저 쫓아내기 등의 기능 가능생성된 토큰을 추적하진 않음. 서버가 아는 것은 토큰의 유효 여부 뿐
인증 뿐 아니라 여러가지 정보를 저장할 수 있다. (ex. 웹사이트 언어 설정)
도메인에 제한된다.

3) 결론

이번 프로젝트에선 JWT를 사용하여 로그인을 구현할 것이다.
이유는 다음과 같다.

  1. csrf.disable()
    CORS 에러 해결을 위한 filter에서 이미 csrf.disable()하였다. 나는 저 방법 외에 CORS 에러를 고칠 수 있는 방법이므로, 세션 기반 인증을 선택할 자유가 없다 🤣 스프링 공식 문서에 따르면, session을 저장하지 않는 REST 서버는 csrf 공격에 자유롭다. 당연하다. 서버에 탈취할 세션이 없기 때문이다.

JWT는 왜 csrf 공격에서부터 자유로운가?

쿠키 기반 인증은 공격자가 탈취할 필요가 없다. 피해자가 자기도 모르게 해당 사이트를 호출하면 자동으로 쿠키도 전송된다.

그러나 JWT의 경우 전송시 HEAD에 세팅하면, 공격자가 어딘가 저장되어 있는 JWT값을 탈취한 후 해당 사이트에 요청를 보내야 한다.

쿠키와 달리 브라우저 저장 변수는 자동으로 서버로 전송되지 않기 때문에
다른 하이제킹 사이트로 클라이언트를 유도하여 요청을 강제해도 토큰이 서버로 날릴 수 없기 때문에 CSRF 공격으로부터 안전하다.

그리고 보다 중요한 이유는...

  1. 그냥 이걸로 해보고 싶다.
    멋지기 때문이다.



2. Spring Security와 JWT를 이용한 로그인

1) 개요

— 기본 로그인 플로우

클라이언트의 login 요청
→ LoginController
→ LoginService: PasswordAuthenticationToken 발급
→ PasswordAuthenticationManager: 비밀번호 매칭 확인 후 유저의 정보를 가지고 있는 Authentication 반환
→ LoginService: SecurityContextHolder에 저장
→ LoginController: token을 돌려준다
→ 로그인

— Custom Filter

스프링시큐리티의 기본 인증 처리 담당 필터인 UsernamePasswordAuthenticationFilter 앞에 커스텀 필터인 JwtFilter를 추가한다. UsernamePasswordAuthenticationFilter필터는 Override되지 않고 CustomAuthenticationProcessingFilter 인증 처리가 되면 자연스럽게 로직을 통과한다.

— ExceptionHandler

로그인 실패 시 발생하는 Exception은 (일단) 전역적으로 핸들링할 클래스를 작성했다.

💡 각종 인터페이스는 생략, 코드에 대한 설명은 주석으로 대체합니다.



2) gradle dependency 추가

	implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
	implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'


3) Controller

그냥 DTO를 받아 token을 반환하는 구조이므로 생략한다.



4) Service 레이어와 인증 객체들

— LoginService

    @Override
    public String login(MemberDto memberDto) {
        log.debug("parameter check: " + memberDto);

        // PasswordAuthAuthenticationToken(UsernamePasswordAuthenticationToken를 상속받은 클래스)의
        // (Object principal, Object credentials) 생성자로 조상 생성자 초기화
        // principal: 사용자 본인 / credentials: 자격 증명
        PasswordAuthenticationToken token = new PasswordAuthenticationToken(memberDto.getId(), memberDto.getPw());

        // 위 객체로 인증하러 가서 PasswordAuthenticationToken 받아옴
        Authentication authentication = passwordAuthAuthenticationManager.authenticate(token);
        log.debug("authenticationManager.authenticate(token): " + authentication);

        // SecurityContextHolder: Spring Security가 인증한 내용들을 저장하는 공감
        // 내부에 SecurityContext가 있고, 이를 현재 스레드와 연결해주는 역할
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 아래 createToken 메소드로 반환받은 token을 보내 JwtAuthToken.token을 반환
        return createToken((PasswordAuthenticationToken) authentication);
    }

    public String createToken(PasswordAuthenticationToken token){
        log.debug("token: " + token);

        // LocalDateTime.now(): 지금 // cf) LocalDate.now(): 오늘
        // .plusMinutes(180): 유효 시간 3시간
        // .atZone(ZoneId.systemDefault()):
        // .toInstant(): Date.from()의 파라미터는 Instant
        //      -> Year 2038 problem을 해결하기 위해서 나온 타입
        // Date.from(): LocalDateTime -> java.util.Date
        Date expiredDate = Date.from(LocalDateTime.now().plusMinutes(180).atZone(ZoneId.systemDefault()).toInstant());

        // Payload: 토큰에 담을 정보가 들어가는 공간
        // claim: 그 정보의 한 조각 (name, value 쌍으로 구성)
        Map<String, String> claims = new HashMap<>();

        claims.put("id", token.getId());
        claims.put("name", token.getName());

        // key를 넣은 JwtAuthToken을 반환
        // String id, Map<String, String> claims, Date expiredDate
        JwtAuthToken jwtAuthToken = tokenProvider.createAuthToken(
                token.getPrincipal().toString(),
                // token.getAuthorities().iterator().next().getAuthority(),
                claims,
                expiredDate
        );

        return jwtAuthToken.getToken();
    }

— passwordAuthAuthenticationManager

@Slf4j
@Service
@RequiredArgsConstructor
public class PasswordAuthAuthenticationManager implements AuthenticationProvider {
    @Autowired
    private final MemberAuthRepository memberAuthRepository;

    @Autowired
    private BCryptPasswordEncoder encoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        /*
        상속도
        Authentication, CredentialsContainer
        ↑
        AbstractAuthenticationToken
        ↑
        UsernamePasswordAuthenticationToken
        ↑
        PasswordAuthenticationToken (내가 만듦)

        => 따라서 Authentication 타입 변수에 담을 수 있다.
        */

        log.debug("PasswordAuthAuthenticationManager.authenticate() 호출");

        // PasswordAuthenticationToken의 principal == user의 id
        // Optional<MemberAuth> memberAuth

        MemberAuth memberAuth = memberAuthRepository.findById(String.valueOf(authentication.getPrincipal()))
                .orElseThrow(() -> new BadCredentialsException("아이디 오류"));

        log.debug("memberAuth: " + memberAuth);

        // user가 입력한 pw와(authentication) DB에서 ID로 조회해온 memberAuth의 pw(credentials)가 같지 않으면
        if(!encoder.matches(authentication.getCredentials().toString(), memberAuth.getPw())){
            log.debug("비밀번호 오류");
            throw new BadCredentialsException("비밀번호 오류");
        }

        log.debug("비밀번호 확인 성공!");

        // PasswordAuthenticationToken token = new PasswordAuthenticationToken(memberAuth.getId(), memberAuth.getPw(), Collections.singleton(new SimpleGrantedAuthority(memberAuth.getRole())));
        // 현 시점으론 role이 필요 없다.
        // 아무튼 조상 생성자(UsernamePasswordAuthenticationToken)를 호출하는 코드
        // 조상의 principal, credentials를 채움
        PasswordAuthenticationToken token = new PasswordAuthenticationToken(memberAuth.getId(), memberAuth.getPw());

        // token 자신의 data 세팅
        token.setId(memberAuth.getId());
        token.setName(memberAuth.getName());

        return token;
    }

— JwtAuthTokenProvider

@Slf4j
@Component
public class JwtAuthTokenProviderImpl implements JwtAuthTokenProvider {

    // property의 값을 읽어오는 어노테이션
    @Value("${secret}")
    private String secret;
    private Key key;


    @PostConstruct // 의존성 주입 후 초기화
    public void init(){
        // base64를 byte[]로 변환
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        // byte[]로 Key 생성
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    @Override
    public JwtAuthToken createAuthToken(String id, Map<String, String> claims, Date expiredDate) {
        return new JwtAuthToken(id, key, claims, expiredDate);
    }

    @Override
    public JwtAuthToken convertAuthToken(String token) {
        // 멤버변수로 지정된 key 값을 포함하여 새로운 JwtAuthToken 객체 지정
        return new JwtAuthToken(token, key);
    }

    @Override
    public Authentication getAuthentication(JwtAuthToken authToken) {
        if(authToken.validate()){
            // authToken에 담긴 데이터를 받아온다
            Claims claims = authToken.getData();
            log.debug(claims.toString());

            // Colletions.singleton(T o): Returns an immutable set containing only the specified object.
            // SimpleGrantedAuthority: 권한 객체. "user" 같은 String 값을 생성자 파라미터로 넣어주면 된다.
            // claims.get(JwtAuthToken.AUTHORITIES_KEY, String.class): 	get(String claimName, Class<T> requiredType)
            // Collection<? extends GrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority(claims.get(JwtAuthToken.AUTHORITIES_KEY, String.class)));
            Collection<? extends GrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("user"));

            // public User(String username, String password, Collection<? extends GrantedAuthority> authorities)
            User principal = new User(claims.getSubject(), "", authorities);

            // UsernamePasswordAuthenticationToken(Object principal, Object credentials,
            //			Collection<? extends GrantedAuthority> authorities)
            return new UsernamePasswordAuthenticationToken(principal, authToken, authorities);
        } else {
            throw new JwtException("token error!");
        }
    }
}

— PasswordAuthenticationToken

// @Data 사용 불가: lombok needs a default constructor in the base class
@Setter
@Getter
@ToString
@Slf4j
public class PasswordAuthenticationToken extends UsernamePasswordAuthenticationToken {
    private String id;
    private String name;

    public PasswordAuthenticationToken(Object principal, Object credentials){
        super(principal, credentials);
    }

    // 혹시나 role이 필요해지면 쓸 코드
    public PasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities){
        super(principal, credentials, authorities);
    }
}

— JwtAuthToken

@Slf4j
public class JwtAuthToken {
    public static final String AUTHORITIES_KEY = "user";
    private final String token;
    private final Key key;

    public JwtAuthToken(String token, Key key) {
        this.token = token;
        this.key = key;
    }

    public JwtAuthToken(String id, Key key, Map<String, String> claims, Date expiredDate){
        this.key = key;
        this.token = createJwtAuthToken(id, claims, expiredDate).get();
    }

    public String getToken(){
        // return token.token;
        return this.token;
    }

    public Optional<String> createJwtAuthToken(String id, Map<String, String> claimMap, Date expiredDate){
        Claims claims = new DefaultClaims(claimMap);
        // claims.put(JwtAuthToken.AUTHORITIES_KEY, role);

        // ofNullable(): 말그대로 null을 허용
        return Optional.ofNullable(Jwts.builder()
                .setSubject(id)
                .addClaims(claims)
                .signWith(key, SignatureAlgorithm.HS256)
                .setExpiration(expiredDate)
                .compact()
        );
    }

    public boolean validate(){
        return getData() != null;
    }

    public Claims getData(){
        try{
            return Jwts
                    // Returns a new JwtParserBuilder instance that can be configured to create an immutable/thread-safe
                    .parserBuilder()
                    // JwtAuthTokenProvider에서 받아온 키 세팅
                    .setSigningKey(key)
                    // JwtParser 객체 리턴
                    .build()
                    // 토큰을 jws로 파싱
                    .parseClaimsJws(token)
                    // 앞서 토큰에 저장한 data들이 담긴 claims를 얻어온다.
                    // String or a Claims instance
                    .getBody();
        } catch(SecurityException e){
            log.info("Invalid JWT signature.");
        } catch(MalformedJwtException e){
            log.info("Invalid JWT token.");
        } catch(ExpiredJwtException e){
            log.info("Expired JWT token.");
        } catch(UnsupportedJwtException e){
            log.info("Unsupported JWT token.");
        } catch(IllegalArgumentException e){
            log.info("JWT token compact of handler are invalid");
        }

        return null;
    }
}

5) Repository 생략

그냥 JpaRepository의 기본 메소드를 활용하였으므로 생략한다.

6) JwtFilter

— JwtFilter


@Slf4j
public class JwtFilter extends OncePerRequestFilter {
    // 로그인 이후 토큰 자체에 대한 검증
    public static final String AUTHORIZATION_HEADER = "Authorization";
    private JwtAuthTokenProvider tokenProvider;

    public JwtFilter(JwtAuthTokenProvider tokenProvider){
        this.tokenProvider = tokenProvider;
    }

    private Optional<String> resolveToken(HttpServletRequest request){
        // Request의 Header에 담긴 토큰 값을 가져온다

        String authToken = request.getHeader(AUTHORIZATION_HEADER);
        log.debug("authToken: " + authToken);

        // 공백 혹은 null이 아니면
        if(StringUtils.hasText(authToken)){
            return Optional.of(authToken);
        } else {
            return Optional.empty();
        }
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.debug("*** JWT FILTER ***");

        Optional<String> header = resolveToken(request);

        // Optional 안의 객체가 null이 아니면
        if(header.isPresent()){
            String token = header.get().split(" ")[1].trim();
            log.debug("split 이후: " + token);

            // header의 token로 token, key를 포함하는 새로운 JwtAuthToken 만들기
            JwtAuthToken jwtAuthToken = (JwtAuthToken) tokenProvider.convertAuthToken(token);

            // boolean validate() -> getData(): claims or null
            // 정상 토큰이면 해당 토큰으로 Authentication을 가져와서 SecurityContext에 저장
            if(jwtAuthToken.validate()){
                // UsernamePasswordAuthenticationToken(유저, authToken, 권한)
                Authentication authentication = tokenProvider.getAuthentication(jwtAuthToken);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }
}

— SpringSecurityConfig

@Configuration
@EnableWebSecurity
public class SpringSecurityConfig {
    private JwtAuthTokenProvider tokenProvider;

    public SpringSecurityConfig(JwtAuthTokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeRequests()
                .antMatchers("/login").permitAll()
                .antMatchers("/register").permitAll()
                .antMatchers("/garden/**").authenticated()
                // .anyRequest().hasRole("USER")

                .and()
                .addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
                .addFilter(this.corsFilter()) // ** CorsFilter 등록 **
                .build();
    }
}

7) ExceptionHandler

— GlobalExceptionHandler

// 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    protected HttpEntity<ErrorResponse> handleException(BadCredentialsException e){
        log.debug("Exception Handler 호출");

        // TODO 에러 메시지는 프론트에서 띄우는 게 낫나?
        ErrorResponse er = new ErrorResponse();
        er.setCode(401);
        er.setMessage("아이디 또는 비밀번호를 잘못 입력했습니다.\n" +
                "입력 내용을 다시 확인해주세요.");

        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(er);
    }
}

3. react 로그인

Login.js

function Login(){
  const [id, setId] = useState("");
  const [pw, setPw] = useState("");
  const [msg, setMsg] = useState("");

  const navigate = useNavigate();

  // 입력 값 확인 및 submit
  const inputCheck = (e) => {
    e.preventDefault(); // reload 막기

    if(!id){
      setMsg("아이디를 입력하세요");
    } else if(!pw){
      setMsg("패스워드를 입력하세요");
    } else {
      // object의 key, value 이름이 같으면 생략 가능
      const data = {id, pw};

      axios.post("/", data)
      .then((res) => {
        // console.log(res);

        const { accessToken } = res.data;

        // API 요청하는 콜마다 헤더에 accessToken 담아 보내도록 설정
        // accessToken을 localStorage, cookie 등에 저장하지 않는다!
        axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;

        // garden 페이지로 이동
        navigate('/garden');
      })
      .catch((error) => {
         // const code = error.response.data.code;
         setMsg(error.response.data.message);

      });
    }
  }

  return ( /* 생략 */ );

이렇게 조촐한 로그인 창 띄우기와 로그인 오류 메시지 띄우기 및 라우팅 주소에 따른 필터 구현에 성공했다. 이제 식물 추가와 물주기 계산 로직을 구현할 차례다. 야호!




참고
JWT 토큰(Token) 기반 인증에 대한 소개
csrf protection을 사용하지 않는 이유
Spring-Boot-2.7.0-Security-Jwt-구현-2
토큰인증이 csrf 공격에 안전한 이유?
Spring Security 커스텀 필터를 이용한 인증 구현 - 스프링시큐리티 설정(2)

0개의 댓글