오티스의개발일기

[SPRING BOOT] 리엑트 네이티브 인스타그램 클론 코딩 (11) JWT 를 사용한 회원정보 상태관리 구현 -git참조- 본문

개발/spring boot

[SPRING BOOT] 리엑트 네이티브 인스타그램 클론 코딩 (11) JWT 를 사용한 회원정보 상태관리 구현 -git참조-

안되면 될때까지.. 2022. 12. 31. 01:28
728x90

 


< 이전글

2022.12.30 - [개발/spring boot] - [SPRING BOOT] 리엑트 네이티브 인스타그램 클론 코딩 (10) 백엔드 회원가입 api 만들기 -git 첨부-

 

[SPRING BOOT] 리엑트 네이티브 인스타그램 클론 코딩 (10) 백엔드 회원가입 api 만들기 -git 첨부-

< 이전글 2022.12.30 - [개발/react-native] - [REACT NATIVE] 리엑트 네이티브 인스타그램 클론 코딩 (9) 구글 로그인후 데이터베이스에 저장하기 + 예외 처리를 통한 비동기 처리 -git 참조- [REACT NATIVE] 리엑트

otis.tistory.com



다음글 >

2023.01.01 - [개발/react-native] - [REACT NATIVE] 인스타그램 클론 코딩 (12) AsyncStorage 를 사용하여 jwt 토큰 저장하기

 

[REACT NATIVE] 인스타그램 클론 코딩 (12) AsyncStorage 를 사용하여 jwt 토큰 저장하기

< 이전글 2022.12.31 - [개발/spring boot] - [SPRING BOOT] 리엑트 네이티브 인스타그램 클론 코딩 (11) JWT 를 사용한 회원정보 상태관리 구현 -git참조- [SPRING BOOT] 리엑트 네이티브 인스타그램 클론 코딩 (11) J

otis.tistory.com


 

 

오늘은 jwt 를 활용하여 회원정보를 토큰에 담아 회원가입 혹은 로그인 한사람한테 그 정보를 넘겨줄것이다.

프론트엔드 클라이언트 부분은 다음시간에 포스팅하도록 하겠다.

 

일단 jwt 를 왜 사용해야하는가? 에대해서 설명을 하겠다.

 

우리가 클라이언트쪽에서 개발을 할때 그사람의 고유 아이디를 가지고 데이터 요청을 했을시

여러가지 단점들이있다.

1. 중복코드가 늘어난다

2. 파라미터로 넘겼을시 보안상 이슈가 있다.

3. 세션만료같은 기능을 구현하려면 복잡해진다.

등등 이 있다.

 

그래서 jwt라는 토큰에 사용자의 정보를 담고

로그인시 클라이언트에 준후

redux 와같은 상태관리 라이브러리를 사용해

accessToken 을 넣어 항상 요청할때마다 꺼내쓰면 되는것이다.

 

그러면 파라미터에 직접 유저아이디를 넣는 번거러움과

토큰 자체는 암호화가 되어있고 헤더를 통해 주고받을거기때문에

행여나 노출이 된다해도 씨크릿코드를 알지 못한다면 풀수가없다.

 

jwt에 대한 자세한 내용은 시간이남으면 좀더 공부해서 포스팅하도록 하겠다.

 

오늘 해볼것은 유저가 가입하거나 로그인했을때

토큰은 헤더에 담아서 보내주는 작업을 할것이다.

 

자 그러면 시작하겠다.

 

아 그리고 시작하기전에 이번 프로잭트 컨트롤러에 변경사항이있다 그러니 쭉 읽어서 빼먹지 말길 바란다.

 

 

# 0. 폴더 구조

 

 

 

#1 .시작하기전 변경사항

 

User.java

package com.example.backend.domain;

import com.sun.istack.NotNull;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;

import javax.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Data
@Builder
@Table(name = "user")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "userNo")
    private Long no; // pk

    private String userName; // 이름

    private String nickName;

    private String email; // 이메일

    private String provider; // 로그인한 sns 브랜드명 예) google

    private String providerId; // 로그인한 sns의 회원 고유번호 예)asdAKSDJjwndjicIAI2314

    private String phone;
    
    //-------------추가됨----------------------

    private String refreshToken; // 리프레쉬 토큰

    @ColumnDefault("'ROLE_USER'")
    private String role; // 권한 default = ROLE_USER

    @ColumnDefault("1")
    private int state; // 상태 1 = 정상 , -99 = 탈퇴

    //-------------추가됨----------------------

    private LocalDateTime regDate;


    @PrePersist
    public void regDate() {
        this.regDate = LocalDateTime.now();
        this.nickName = this.userName;
    }
}

 

이번 포스팅 작업을 하면서 추가된 유저 컬럼들이 있다. 최대한 순서대로 하려고 노력하는데 순서대로 면 에러같은걸 볼수도있을것이다.

일단 처음부터 끝까지 다 읽고 그리고 작업하기 바란다.

 

그리고 컨트롤러의 변경사항이있다.

원래 UserController -> CommonUserController 로 이름이 바뀌었다.

그래서 CommonUserController.java 를 새로만들고 UserController 에 있는 코드를 옮기기 바란다.

 

이작업을 한 이유는 후에 알려드릴 interceptor 때문이다.

어떠한 경로를 지정해 그곳에 요청이 들어가기 직전 가로채 토큰을 확인하는데

우리가 현재 작업하는건 로그인 혹은 회원가입이기떄문에

당연히 토큰이 없는 상태이다.

 

한마디로 CommonUserContoller 는 회원과 관련된 컨트롤러 이기는 하나 

로그인이 안되있는 상태에서 요청하는 컨트롤러라 생각하면된다.

 

 

UserController.java

 

package com.example.backend.controller;

import com.example.backend.common.exception.CustomApiException;
import com.example.backend.dto.CMRespDto;
import com.example.backend.dto.ResponseMap;
import com.example.backend.dto.user.request.RequestUserEmailDoubleCheckDto;
import com.example.backend.dto.user.request.RequestUserRegisterDto;
import com.example.backend.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import javax.validation.constraints.Email;

@RestController
@RequiredArgsConstructor
@CrossOrigin(origins = {"*"})
@RequestMapping("/api/user")
public class UserController {

}

 

CommonUserController.java

 

package com.example.backend.controller;

import com.example.backend.dto.CMRespDto;
import com.example.backend.dto.ResponseMap;
import com.example.backend.dto.user.request.RequestUserEmailDoubleCheckDto;
import com.example.backend.dto.user.request.RequestUserRegisterDto;
import com.example.backend.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;

@RestController
@RequiredArgsConstructor
@CrossOrigin(origins = {"*"})
@RequestMapping("/api/common/user")
public class CommonUserController {

    private final UserService userService;

    /**
     * 일반 회원가입
     * @param requestUserRegisterDto
     * @return
     */
    @PostMapping("/register")
    public CMRespDto<?> register (@Valid @RequestBody RequestUserRegisterDto requestUserRegisterDto, HttpServletRequest request, HttpServletResponse response) {
        return new CMRespDto<>(200, "회원가입이 완료되었습니다.", userService.register(requestUserRegisterDto, request, response));
    }

    /**
     * 아이디 중복체크
     * @param requestUserRegisterDto
     * @return
     */
    @PostMapping("/userEmailDoubleCheck")
    public CMRespDto<?> userEmailDoubleCheck (@Valid @RequestBody RequestUserEmailDoubleCheckDto requestUserRegisterDto) {
        return new CMRespDto<>(200, "사용 가능한 이메일 입니다.", userService.userEmailDoubleCheck(requestUserRegisterDto));
    }

    /**
     * 소셜 로그인
     * @param requestUserRegisterDto
     * @return
     */
    @PostMapping("/snsLogin")
    public CMRespDto<?> snsLogin (@Valid @RequestBody RequestUserRegisterDto requestUserRegisterDto, HttpServletRequest request, HttpServletResponse response) {
        ResponseMap responseMap = userService.snsLogin(requestUserRegisterDto, request, response);
        return new CMRespDto<>(responseMap.getHttpStatus().value(), responseMap.getMessage(), null);
    }

    /**
     * 소셜 로그인 연동
     * @param requestUserRegisterDto
     * @return
     */
    @PostMapping("/updateProvider")
    public CMRespDto<?> updateProvider (@Valid @RequestBody RequestUserRegisterDto requestUserRegisterDto) {
        ResponseMap responseMap = userService.updateProvider(requestUserRegisterDto);
        return new CMRespDto<>(responseMap.getHttpStatus().value(), responseMap.getMessage(), null);
    }
}

 

 

 

 

 

#2 . WebConfiguration.java

package com.example.backend.config;

import com.example.backend.common.jwt.JwtInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebConfiguration implements WebMvcConfigurer {
    private final JwtInterceptor jwtInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtInterceptor)
                .addPathPatterns("/api/user/**");
    }
}

 

 

아직 jwInterceptor 을 만들지 않았지만

이 파일에대해 설명해보겠다.

일단 Interceptor 란 우리가 어떤 요청을 보냈을때 요청이 우리가 만든 컨트롤러 -> 서비스 등

가기전에 이 인터셉터가 그 요청을 가로챈다. 물로 저기 addPathPatterens 에 정의된 경로로 들어왔을때만이다.

 

그러면 우리가 할수있는건 로그인이 된 유저가 자신의 정보나 어떤 데이터를 요청했을때 그때 JwtInterceptor 라는 클래스 내부에서

클라이언트가 가지고있는 토큰이 있는지 없는지 유효한지 등 확인을 하고 

없으면 그에따른 customException 을이르켜 클라이언트한테 세션이 만료되었습니다 등 이런 메시지를 전달할수 있게된다.

 

 

이제 저기 상단에있는 JwtInterceptor 을 만들어보자

 

 

 

# 3. JwtInterceptor.java 생성

package com.example.backend.common.jwt;

import com.example.backend.common.exception.CustomApiException;
import com.example.backend.domain.User;
import com.example.backend.repository.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

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

@Component
@RequiredArgsConstructor
public class JwtInterceptor implements HandlerInterceptor {

    private static final String ACCESS_TOKEN = "accessToken";
    private static final String REFRESH_TOKEN = "refreshToken";

    private static final int TEN_MINUTE = 1000 * 60 * 10;

    private final JwtService jwtService;
    private final UserRepository userRepository;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        final String accessToken = request.getHeader(ACCESS_TOKEN);
        final String refreshToken = request.getHeader(REFRESH_TOKEN);

        if (!accessToken.isEmpty() && jwtService.isUsable(accessToken)) { // accessToken 가 존재하고 사용이 가능하다면 로그인이 되있는 상태
            if (jwtService.isExpire(accessToken)) { // 토큰이 유효한지 확인
                if (!refreshToken.isEmpty()) { // 리프레쉬 토큰이 존재한다면
                    if (!jwtService.isExpire(refreshToken)) { // 리프레쉬 토큰이 아직 유효하다면
                        User user = userRepository.findByRefreshToken(refreshToken);
                        if (user != null) {
                            JWTUserMap jwtUserMap = new JWTUserMap(user);
                            String newAccessToken = jwtService.createAccessToken(user.getNo()+"", TEN_MINUTE, jwtUserMap.getJWTMap());
                            response.setHeader(ACCESS_TOKEN, newAccessToken);
                            return true;
                        } else {
                            throw new CustomApiException("존재하지 않는 계정입니다.", HttpStatus.BAD_REQUEST);
                        }
                    } else {
                        throw new CustomApiException("로그인 세션이 만료되었습니다 다시로그인해주세요.", HttpStatus.BAD_REQUEST);
                    }
                } else {
                    throw new CustomApiException("로그인 세션이 만료되었습니다 다시로그인해주세요.", HttpStatus.BAD_REQUEST);
                }
            }

            if (jwtService.isExpire(refreshToken)) {
                throw new CustomApiException("로그인 세션이 만료되었습니다 다시로그인해주세요.", HttpStatus.BAD_REQUEST);
            }
            return true;
        } else { // accessToken 존재하지 않음 == 로그아웃
            throw new CustomApiException("로그인이 필요한 서비스입니다..", HttpStatus.BAD_REQUEST);
        }

    }
}

 

 

대부분에 것은 주석에 적어놨다 하나한 읽으면서 이해하기 바란다 

 

여기서 가장 중요한건 accessToken 과 refreshToken 이다.

accessToken 은 사용자의 정보를 가지고있고

refreshToken 은 아무런 정보가 없는 토큰이다.

 

아무런 정보가 없는데 왜 필요하지????

User user = userRepository.findByRefreshToken(refreshToken);

 우리는 리프레쉬 토큰은 사용자의 정보에 저장해놨다가

실질적인 엑세스 토큰의 기간이 만료됬을때 가지고있는 리프레쉬 토큰과 유저의 엑세스 토큰을 대조해

있으면 엑세스 토큰을 재발급 해주는 로직이다.

 

그러므로 리프레쉬 토큰은 유효기간은 1일로 길~게

엑세스 토큰은 유효기간은 10분 혹은 더짧게 해도된다.

 

 

이제 상단에 있는 jwtService 를 만들어 보겠다 

 

 

 

 

# 4. jwtService.java

package com.example.backend.common.jwt;


import com.example.backend.common.exception.CustomApiException;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;

import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import java.util.Date;
import java.util.Map;

@Slf4j
@Service("jwtService")
public class JwtService {

    private static final String SECRET_KEY = "accessToken"; // production 모드에서는 좀더 어려운걸 적어야함

    // accessToken 토큰 발행
    public String createAccessToken(String subject, long time, Map<String, Object> userData) {
        if (time <= 0) {
            throw new RuntimeException("Expiry time must be greater than Zero : ["+time+"] ");
        }
        // 토큰을 서명하기 위해 사용해야할 알고리즘 선택
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

        byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY);
        Key signingKey = new SecretKeySpec(secretKeyBytes, signatureAlgorithm.getJcaName());
        JwtBuilder builder = Jwts.builder()
                .claim("userData", userData)
                .setSubject(subject)
                .signWith(signatureAlgorithm, signingKey);
        long nowTime = System.currentTimeMillis();
        builder.setExpiration(new Date(nowTime + time));
        return builder.compact();
    }

    // refresh 토큰 발행
    public String createRefreshToken(long time) {
        long nowTime = System.currentTimeMillis();
        return Jwts.builder()
                .setExpiration(new Date(nowTime + time))
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();
    }

    // 토큰의 payload 속의 userId를 찾는다
    public Long getUserId(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(DatatypeConverter.parseBase64Binary(SECRET_KEY))
                .parseClaimsJws(token).getBody();
        return Long.valueOf(claims.getSubject());
    }

    // 토큰의 payload 속의 userData를 찾는다
    public Claims getUserData(String token) {
        Claims claims;
        try {
            claims = Jwts.parser()
                    .setSigningKey(SECRET_KEY)
                    .parseClaimsJws(token)
                    .getBody();
        } catch (Exception e) {
            e.printStackTrace();
            claims = null;
        }
        return claims;
    }

    // 발급해준 토큰이 맞는지 체크
    public boolean isUsable(String jwt) {
        try{
            Claims claims = Jwts.parser()
                    .setSigningKey(DatatypeConverter.parseBase64Binary(SECRET_KEY))
                    .parseClaimsJws(jwt).getBody();
            return true;
        }catch (Exception e) {
            throw new CustomApiException("회원만 이용가능한 서비스입니다.", HttpStatus.BAD_REQUEST);
        }
    }

    // 토큰 만료시간이 지났는지 체크
    public boolean isExpire(String token) {
        boolean isExpire = false;
        try {
            isExpire = Jwts.parser()
                    .setSigningKey(SECRET_KEY)
                    .parseClaimsJws(token)
                    .getBody()
                    .getExpiration()
                    .before(new Date());
        } catch (Exception e) {
            log.warn(e.getMessage());
        }

        return isExpire;
    }



}

 

 

 

private static final String SECRET_KEY = "accessToken"; // production 모드에서는 좀더 어려운걸 적어야함

이부분의 씨크릿코드는 절대 탈취당해서는 안되는 중요한 코드이다 

연습이기 때문에 저렇게 적어놨지만 실제 프로덕트에서는 저렇게했을시 

다른사람의 정보가 빠져나갈수도있다.

 

 

 

사용자의 정보를 담을 JWTUserMap 을 생성해 보겠다

 

 

 

# 5. JWTUserMap.java

package com.example.backend.common.jwt;

import com.example.backend.domain.User;
import lombok.Data;

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

@Data
public class JWTUserMap {

    private Long userNo;
    private String userName;
    private String nickName;
    private String role;
    private String phone;
    private int state;

    public JWTUserMap(User user) {
        this.userNo = user.getNo();
        this.userName = user.getUserName();
        this.role = user.getRole();
        this.phone = user.getPhone();
        this.state = user.getState();
        this.nickName = user.getNickName();
    }

    public Map<String, Object> getJWTMap () {
        Map<String, Object> map = new HashMap<>();
        map.put("userNo", this.userNo);
        map.put("userName", this.userName);
        map.put("nickName", this.nickName);
        map.put("phone", this.phone);
        map.put("role", this.role);
        map.put("state", this.state);
        return map;
    }
}

 

 

 

 

이제 필요한 파일들은 다 잘 만들었다 이제 프론트에서 토큰들이 잘 오는지 콘솔에 찍어보도록 하겠다.

 

if (res.data.code === 200) { // 정상 코드가 들어올시 비지니스로직 진행
console.log('엑세스 토큰 = ' + res.headers.accesstoken)
console.log('리프레쉬 토큰 = ' + res.headers.refreshtoken)
alert(res.data.message);
}

 

 

 

정상적으로 동작하는걸 확인할수 있다.

 

다음포스팅은 이 토큰을 활용하여 redux 에 토큰을 저장하는 작업을 해보겠다.

 

 

https://github.com/1domybest/react-native-ig-clone.git

 

GitHub - 1domybest/react-native-ig-clone

Contribute to 1domybest/react-native-ig-clone development by creating an account on GitHub.

github.com


다음글 >

2023.01.01 - [개발/react-native] - [REACT NATIVE] 인스타그램 클론 코딩 (12) AsyncStorage 를 사용하여 jwt 토큰 저장하기

 

[REACT NATIVE] 인스타그램 클론 코딩 (12) AsyncStorage 를 사용하여 jwt 토큰 저장하기

< 이전글 2022.12.31 - [개발/spring boot] - [SPRING BOOT] 리엑트 네이티브 인스타그램 클론 코딩 (11) JWT 를 사용한 회원정보 상태관리 구현 -git참조- [SPRING BOOT] 리엑트 네이티브 인스타그램 클론 코딩 (11) J

otis.tistory.com


 

728x90
Comments