심화 - Stateless 설계

변현섭·2023년 9월 13일
0
post-thumbnail

실시간 채팅을 위한 모든 기능을 추가하였지만, 부가적인 기능에 대해서는 아직도 해야 할 일이 많습니다. 물론, 모든 부가적인 기능을 다루기는 어렵겠으나, 몇몇 일반적인 기능 정도는 추가해보려 합니다.

즉, 심화 단계에서 다뤄볼 내용은 애플리케이션이 동작하기 위해 필수적인 기능은 아니지만 있으면 좋을만한 기능들, 완성도를 보다 높이기 위한 기능들에 대해 소개해보겠습니다. 그 중에서 오늘은 access token과 refresh token을 클라이언트에게 저장(Stateful)시킴으로써, 서버를 Stateless 상태로 만드는 방법을 알아보도록 하겠습니다.

1. Stateless 설계

1) 백엔드

① JwtService를 아래와 같이 수정한다.

  • 이제 더 이상 서버에서 Token 정보를 저장할 필요가 없다.
@Service
@RequiredArgsConstructor
public class JwtService {
    private Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(Secret.JWT_SECRET_KEY));
    private final JwtProvider jwtProvider;
    private final RedisTemplate redisTemplate;
    private final UtilService utilService;

    public String getJwt() {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        return request.getHeader("Authorization");
    }

    /**
     * JWT에서 userId 추출
     */
    public Long getUserIdx() throws BaseException {
        // 1. JWT 추출
        String accessToken = getJwt();
        if (accessToken == null || accessToken.length() == 0) {
            throw new BaseException(BaseResponseStatus.EMPTY_JWT);
        }
        if (checkBlackToken(accessToken)) {
            throw new BaseException(BaseResponseStatus.LOG_OUT_USER);
        }
        try {
            // 2. JWT parsing
            Jws<Claims> claims = Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(accessToken);
            // 3. userId 추출
            Long userId = claims.getBody().get("userId", Long.class);
            User user = utilService.findByUserIdWithValidation(userId);

            return userId;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            throw new BaseException(BaseResponseStatus.INVALID_JWT);
        } catch (Exception ignored) {
            throw new BaseException(BaseResponseStatus.INVALID_JWT);
        }
    }

    /**
     * 로그아웃 전용 userId 추출 메서드
     */
    // 로그아웃을 시도할 때는 accsee token과 refresh 토큰이 만료되었어도
    // 형식만 유효하다면 토큰 재발급 없이 로그아웃 할 수 있어야 함.
    public Long getLogoutUserIdx() throws BaseException {

        // 1. JWT 추출
        String accessToken = getJwt();
        if (accessToken == null || accessToken.length() == 0) {
            throw new BaseException(BaseResponseStatus.EMPTY_JWT);
        }
        if (checkBlackToken(accessToken)) {
            throw new BaseException(BaseResponseStatus.LOG_OUT_USER);
        }
        try {
            // 2. JWT parsing
            Jws<Claims> claims = Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(accessToken);
            // 3. userId 추출
            return claims.getBody().get("userId", Long.class);
        } catch (ExpiredJwtException e) {
            // access token이 만료된 경우
            return 0L;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            throw new BaseException(BaseResponseStatus.INVALID_JWT);
        } catch (Exception ignored) {
            throw new BaseException(BaseResponseStatus.INVALID_JWT);
        }
    }

    /**
     * 토큰의 만료 여부를 판별
     */
    public Boolean checkExpiration() throws BaseException {
        // 1. JWT 추출
        String accessToken = getJwt();
        if (accessToken == null || accessToken.length() == 0) {
            throw new BaseException(BaseResponseStatus.EMPTY_JWT);
        }
        if (checkBlackToken(accessToken)) {
            throw new BaseException(BaseResponseStatus.LOG_OUT_USER);
        }
        try {
            // 2. JWT parsing
            Jws<Claims> claims = Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(accessToken);
            // 3. userId 추출
            return false; // 유효
        } catch (ExpiredJwtException e) {
            // access token 만료
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            throw new BaseException(BaseResponseStatus.INVALID_JWT);
        } catch (Exception ignored) {
            throw new BaseException(BaseResponseStatus.INVALID_JWT);
        }
    }

    /**
     * 액세스 토큰 재발급
     */
    public String refreshAccessToken(PostReissueReq postReissueReq) throws BaseException {
        try {
            String refreshToken = postReissueReq.getRefreshToken();
            String uid = postReissueReq.getUid();
            // 리프레시 토큰이 만료 등의 이유로 유효하지 않은 경우
            if (!jwtProvider.validateToken(refreshToken)) {
                throw new BaseException(BaseResponseStatus.INVALID_JWT);
            }
            else { // 리프레시 토큰이 유효한 경우
                User user = utilService.findByUserUidWithValidation(uid);
                Long userId = user.getId();
                String refreshedAccessToken = jwtProvider.createToken(userId);
                // 액세스 토큰 재발급에 성공한 경우
                if (refreshedAccessToken != null) {
                    return refreshedAccessToken;
                }
                throw new BaseException(BaseResponseStatus.FAILED_TO_REFRESH);
            }
        } catch (BaseException exception) {
            throw new BaseException(exception.getStatus());
        }
    }

    /**
     * Redis 블랙 리스트 등록 여부 확인
     */
    private boolean checkBlackToken(String accessToken) {
        // Redis에 있는 엑세스 토큰인 경우 로그아웃 처리된 엑세스 토큰이다.
        Object redisToken = redisTemplate.opsForValue().get(accessToken);
        if (redisToken != null) { // Redis에 저장된 토큰이면 블랙토큰
            return true;
        }
        return false;
    }
}

② access token의 만료 여부를 판단하는 API와 refresh token으로 access token을 재발급하는 API를 UserController에 추가한다.

/**
 * 액세스 토큰의 만료 여부를 판별
 */
@GetMapping("/check-token")
public BaseResponse<Boolean> checkExpiration() {
    try  {
        return new BaseResponse<>(jwtService.checkExpiration());
    } catch (BaseException exception){
        return new BaseResponse<>(exception.getStatus());
    }
}

/**
 * 리프레시 토큰으로 액세스토큰을 재발급
 */
@PostMapping("/reissue-token")
public BaseResponse<String> reissueToken(@RequestBody PostReissueReq postReissueReq) {
    try  {
        return new BaseResponse<>(jwtService.refreshAccessToken(postReissueReq));
    } catch (BaseException exception){
        return new BaseResponse<>(exception.getStatus());
    }
}

③ user > dto 패키지 하위로 PostReissueReq 클래스를 추가한다.

@AllArgsConstructor
@NoArgsConstructor
@Getter
public class PostReissueReq {
    private String uid;
    private String refreshToken;
}

④ UserService를 아래와 같이 수정한다.

@EnableTransactionManagement
@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;
    private final UtilService utilService;
    private final JwtProvider jwtProvider;
    private final ProfileRepository profileRepository;
    private final ProfileService profileService;
    private final S3Service s3Service;

    /**
     * 유저 생성 후 DB에 저장(회원 가입) with JWT
     */
    @Transactional
    public PostUserRes createUser(PostUserReq postUserReq) throws BaseException {
        if(userRepository.findByEmailCount(postUserReq.getEmail()) >= 1) {
            throw new BaseException(BaseResponseStatus.POST_USERS_EXISTS_EMAIL);
        }
        if(postUserReq.getPassword().isEmpty()){
            throw new BaseException(BaseResponseStatus.PASSWORD_CANNOT_BE_NULL);
        }
        if(!postUserReq.getPassword().equals(postUserReq.getPasswordChk())) {
            throw new BaseException(BaseResponseStatus.PASSWORD_MISSMATCH);
        }

        if(postUserReq.getNickName() == null || postUserReq.getNickName().isEmpty()) {
            throw new BaseException(BaseResponseStatus.NICKNAME_CANNOT_BE_NULL);
        }

        String pwd;
        try{
            pwd = new AES128(Secret.USER_INFO_PASSWORD_KEY).encrypt(postUserReq.getPassword()); // 암호화 코드
        }
        catch (Exception ignored) { // 암호화가 실패하였을 경우 에러 발생
            throw new BaseException(BaseResponseStatus.PASSWORD_ENCRYPTION_ERROR);
        }
        User user = new User();
        user.createUser(postUserReq.getNickName(),postUserReq.getEmail(), pwd, null);
        userRepository.save(user);

        return new PostUserRes(user);
    }

    /**
     * 유저 로그인 with JWT
     */
    public PostLoginRes login(PostLoginReq postLoginReq) throws BaseException {
        User user = utilService.findByEmailWithValidation(postLoginReq.getEmail());
        user.setUid(postLoginReq.getUid());

        String password;
        try {
            password = new AES128(Secret.USER_INFO_PASSWORD_KEY).decrypt(user.getPassword());
        } catch (Exception ignored) {
            throw new BaseException(BaseResponseStatus.PASSWORD_DECRYPTION_ERROR);
        }

        if (postLoginReq.getPassword().equals(password)) {
            JwtResponseDto.TokenInfo tokenInfo = jwtProvider.generateToken(user.getId());
            Token token = Token.builder()
                        .accessToken(tokenInfo.getAccessToken())
                        .refreshToken(tokenInfo.getRefreshToken())
                        .user(user)
                        .build();

            return new PostLoginRes(user, token);
        } else {
            throw new BaseException(BaseResponseStatus.PASSWORD_NOT_MATCH);
        }
    }

    /**
     * 유저 정보 반환
     */
    public GetUserRes getUserInfo(String uid) throws BaseException {
        User user = utilService.findByUserUidWithValidation(uid);
        String profileUrl = (user.getProfile() != null) ? user.getProfile().getProfileUrl() : null;
        String nickName = user.getNickName();

        return new GetUserRes(uid, profileUrl, nickName);
    }

    /**
     *  유저 닉네임 변경
     */
    @Transactional
    public String modifyUserNickName(Long userId, String nickName) throws BaseException {
        User user = utilService.findByUserIdWithValidation(userId);
        user.setNickName(nickName);
        return "회원정보가 수정되었습니다.";
    }

    /**
     *  유저 비밀번호 변경
     */
    @Transactional
    public String modifyPassword(Long userId, PatchPasswordReq patchPasswordReq) throws BaseException {
        try {
            User user = utilService.findByUserIdWithValidation(userId);
            String password;
            try {
                password = new AES128(Secret.USER_INFO_PASSWORD_KEY).decrypt(user.getPassword());
            } catch (Exception ignored) {
                throw new BaseException(BaseResponseStatus.PASSWORD_DECRYPTION_ERROR);
            }
            // 이전 비밀번호가 일치하지 않는 경우
            if (!patchPasswordReq.getExPassword().equals(password)) {
                throw new BaseException(BaseResponseStatus.EX_PASSWORD_MISSMATCH);
            }
            // 이전 비밀번호와 새 비밀번호가 일치하는 경우
            if(patchPasswordReq.getNewPassword().equals(patchPasswordReq.getExPassword())) {
                throw new BaseException(BaseResponseStatus.CANNOT_UPDATE_PASSWORD);
            }
            // 새 비밀번호와 새 비밀번호 확인이 일치하지 않는 경우
            if(!patchPasswordReq.getNewPassword().equals(patchPasswordReq.getNewPasswordChk())) {
                throw new BaseException(BaseResponseStatus.PASSWORD_MISSMATCH);
            }

            String pwd;
            try{
                pwd = new AES128(Secret.USER_INFO_PASSWORD_KEY).encrypt(patchPasswordReq.getNewPassword()); // 암호화코드
            }
            catch (Exception ignored) { // 암호화가 실패하였을 경우 에러 발생
                throw new BaseException(BaseResponseStatus.PASSWORD_ENCRYPTION_ERROR);
            }
            user.setPassword(pwd);
            return "비밀번호 변경이 완료되었습니다.";
        } catch (BaseException exception) {
            throw new BaseException(exception.getStatus());
        }
    }

    /**
     *  유저 프로필 변경
     */
    @Transactional
    public String modifyProfile(Long userId, MultipartFile multipartFile) throws BaseException {
        try {
            User user = utilService.findByUserIdWithValidation(userId);
            Profile profile = profileRepository.findProfileById(userId).orElse(null);
            if(profile == null) { // 프로필이 미등록된 사용자가 변경을 요청하는 경우
                GetS3Res getS3Res;
                if(multipartFile != null) {
                    getS3Res = s3Service.uploadSingleFile(multipartFile);
                    profileService.saveProfile(getS3Res, user);
                }
            }
            else { // 프로필이 등록된 사용자가 변경을 요청하는 경우
                // 1. 버킷에서 삭제
                profileService.deleteProfile(profile);
                // 2. Profile Repository에서 삭제
                profileService.deleteProfileById(userId);
                if(multipartFile != null) {
                    GetS3Res getS3Res = s3Service.uploadSingleFile(multipartFile);
                    profileService.saveProfile(getS3Res, user);
                }
            }
            return "프로필 수정이 완료되었습니다.";
        } catch (BaseException exception) {
            throw new BaseException(exception.getStatus());
        }
    }

    /**
     *  기본 프로필 적용
     */
    @Transactional
    public String modifyNoProfile(Long userId) throws BaseException {
        try {
            User user = utilService.findByUserIdWithValidation(userId);
            Profile profile = profileRepository.findProfileById(userId).orElse(null);
            if(profile != null) { // 프로필이 미등록된 사용자가 변경을 요청하는 경우
                // 1. 버킷에서 삭제
                profileService.deleteProfile(profile);
                // 2. Profile Repository에서 삭제
                profileService.deleteProfileById(userId);
            }
            return "기본 프로필이 적용됩니다.";
        } catch (BaseException exception) {
            throw new BaseException(exception.getStatus());
        }
    }

    /**
     * 모든 유저의 닉네임과 프로필 사진 반환
     */
    public List<GetUserRes> getUsers(Long userId) {
        utilService.findByUserIdWithValidation(userId);
        List<User> users = userRepository.findUserByIdWithoutMe(userId);

        List<GetUserRes> getUserResList = users.stream()
                .map(user -> {
                    String profileUrl = (user.getProfile() != null) ? user.getProfile().getProfileUrl() : null;
                    return new GetUserRes(user.getUid(), profileUrl, user.getNickName());
                })
                .sorted(Comparator.comparing(GetUserRes::getNickName))
                .collect(Collectors.toList());

        return getUserResList;
    }

    /**
     *  유저 탈퇴
     */
    @Transactional
    public String deleteUser(Long userId, String agreement) throws BaseException{
        if(!agreement.equals("I agree")) {
            throw new BaseException(BaseResponseStatus.AGREEMENT_MISMATCH);
        }
        User user = utilService.findByUserIdWithValidation(userId);
        Profile profile = profileRepository.findProfileById(userId).orElse(null);
        if(profile != null) {
            profileService.deleteProfile(profile);
            profileRepository.deleteProfileById(userId);
        }
        userRepository.deleteUser(userId);
        String result = "요청하신 회원에 대한 삭제가 완료되었습니다.";
        return result;
    }
}

⑤ TokenReposiotry를 삭제하고 UtilService에서 accessToken으로 유저를 찾는 메서드도 삭제한다.

⑥ 원활한 테스트를 위해서 JwtProvider에서 accessToken의 유효시간을 1분으로 바꿔보자.

  • access token이 만료되었을 때 재발급이 잘 이루어지는지 확인하기 위해 임시로 설정하는 값이다.
private static final long ACCESS_TOKEN_EXPIRE_TIME = 60 * 1000L; // accessToken 유효기간 : 1분

2) 프론트엔드

원칙적으로는 accessToken을 받는 모든 API에서, 토큰의 만료 여부를 확인한 후 재발급하는 로직을 일일이 추가해주어야 하지만, 여기서는 구현의 편의성을 위해 SplashActivity에만 재발급 로직을 추가해주도록 하겠다.

이렇게 구현하는 이유는 현재 액세스 토큰의 만료시간이 12시간이나 되기 때문이다. 즉, 앱을 12시간 내내 켜놓지만 않는다면 전혀 문제를 일으키지 않을 것이다. 물론 12시간 내내 켜놓으면, 아무런 요청도 할 수 없는 상태가 되겠지만, 설령 이러한 일이 생기더라도 앱을 종료했다가 다시 접속만 하면 해결되기 때문에 큰 문제는 아닐 거 같다. (물론, 실제 서비스에서는 이렇게 하면 안 된다.)

① UserApi에 아래의 API를 추가한다.

@GET("/users/check-token")
suspend fun checkExpiration(
    @Header("Authorization") accessToken : String
) : BaseResponse<Boolean>

@POST("/users/reissue-token")
suspend fun reissueToken(@Body postReissueReq: PostReissueReq) : BaseResponse<String>

② api > dto에 PostReissueReq라는 data class를 추가한다.

data class PostReissueReq(
    @SerializedName("uid")
    val uid : String,

    @SerializedName("refreshToken")
    val refreshToken : String
)

③ SplashActivity를 아래와 같이 수정한다.

  • 만약 모든 API에 대해 재발급 로직을 추가하고 싶다면 [] 표시한 부분에서 API를 호출하면 된다.
@Suppress("DEPRECATION")
class SplashActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_splash)

        val uid = FirebaseAuthUtils.getUid()
        Log.d("uid", uid)

        if(uid == null) { // 신규 유저 -> 회원가입 페이지로 전환
            Handler().postDelayed({
                startActivity(Intent(this, IntroActivity::class.java))
                finish()
            }, 2000)
        }

        else { // 기존 유저
            getAccessToken { accessToken -> // 액세스 토큰 이용
                if(accessToken == null) { // 회원가입만 하고, 로그인은 한번도 한 적 없는 유저 -> 로그인 페이지로 전환
                    Handler().postDelayed({
                        startActivity(Intent(this, LoginActivity::class.java))
                        finish()
                    }, 2000)
                }
                if (accessToken.isNotEmpty()) { // 일반적인 유저
                    CoroutineScope(Dispatchers.IO).launch {
                        val expirationChk = checkExpiration(accessToken) // 액세스 토큰의 만료 여부 확인
                        if(expirationChk.isSuccess) {
                            if(expirationChk.result!!) { // 액세스 토큰이 만료되었으면
                                getRefreshToken { refreshToken ->
                                    if (refreshToken.isNotEmpty()) {
                                        CoroutineScope(Dispatchers.IO).launch {
                                            val postReissueReq = PostReissueReq(FirebaseAuthUtils.getUid(), refreshToken)
                                            val response = reissueToken(postReissueReq) // 리프레시 토큰으로 액세스 토큰 재발급
                                            if(response.isSuccess) {
                                                val newAccessToken = response.result // 파이어베이스의 기존 accessToekn을 newAccessToken으로 바꿈
                                                FirebaseRef.userInfo.child(uid).child("accessToken").setValue(newAccessToken)
                                                
                                                // [만약 모든 API에 재발급 로직을 추가한다면, 여기에서 newAccessToken을 이용하여 API를 호출하면 된다]
                                                
                                                withContext(Dispatchers.Main) {
                                                    Handler().postDelayed({
                                                        startActivity(Intent(this@SplashActivity, MainActivity::class.java))
                                                        finish()
                                                    }, 2000)
                                                }
                                            }
                                            else { // 리프레시 토큰이 만료되었거나 유효하지 않은 경우 -> 로그인 화면으로 전환
                                                withContext(Dispatchers.Main) {
                                                    Toast.makeText(this@SplashActivity, "세션이 만료되어 로그인 후 이용 가능합니다", Toast.LENGTH_SHORT).show()
                                                    val intent = Intent(this@SplashActivity, LoginActivity::class.java)
                                                    startActivity(intent)
                                                }
                                            }
                                        }
                                    }
                                    else { // access token은 Not Empty이면서, refresh token만 Empty 한 경우(거의 일어나지 않을 상황) -> 회원가입 페이지로 전환
                                        Log.e("SplashActivity", "Invalid Token")
                                        Handler().postDelayed({
                                            startActivity(Intent(this@SplashActivity, IntroActivity::class.java))
                                            finish()
                                        }, 2000)
                                    }

                                }
                            }
                            else { // 액세스 토큰이 만료되지 않은 경우
                                
                                // [만약 모든 API에 재발급 로직을 추가한다면, 여기에서 newAccessToken을 이용하여 API를 호출하면 된다]
                                
                                withContext(Dispatchers.Main) {
                                    Handler().postDelayed({
                                        startActivity(Intent(this@SplashActivity, MainActivity::class.java))
                                        finish()
                                    }, 2000)
                                }
                            }
                        }
                        else { // 유효하지 않은 토큰(ex. 위변조된 토큰)의 접근 -> 회원가입 페이지로 전환
                            Log.d("SplashActivity", "Invalid User")
                            val message = expirationChk.message
                            Log.d("SplashActivity", message)
                            withContext(Dispatchers.Main) {
                                Toast.makeText(this@SplashActivity, message, Toast.LENGTH_SHORT).show()
                                Handler().postDelayed({
                                    startActivity(Intent(this@SplashActivity, IntroActivity::class.java))
                                    finish()
                                }, 2000)
                            }
                        }
                    }
                }

                else {
                    Handler().postDelayed({
                        startActivity(Intent(this, LoginActivity::class.java))
                        finish()
                    }, 2000)
                }
            }
        }
    }

    private fun getAccessToken(callback: (String) -> Unit) {
        val postListener = object : ValueEventListener {
            override fun onDataChange(dataSnapshot: DataSnapshot) {
                val data = dataSnapshot.getValue(com.chrome.chattingapp.authentication.UserInfo::class.java)
                val accessToken = data?.accessToken ?: ""

                callback(accessToken)
            }

            override fun onCancelled(databaseError: DatabaseError) {
                Log.w("NickNameActivity", "onCancelled", databaseError.toException())
            }
        }

        FirebaseRef.userInfo.child(FirebaseAuthUtils.getUid()).addListenerForSingleValueEvent(postListener)
    }

    private fun getRefreshToken(callback: (String) -> Unit) {
        val postListener = object : ValueEventListener {
            override fun onDataChange(dataSnapshot: DataSnapshot) {
                val data = dataSnapshot.getValue(com.chrome.chattingapp.authentication.UserInfo::class.java)
                val refreshToken = data?.refreshToken ?: ""
                callback(refreshToken)
            }

            override fun onCancelled(databaseError: DatabaseError) {
                Log.w("NickNameActivity", "onCancelled", databaseError.toException())
            }
        }

        FirebaseRef.userInfo.child(FirebaseAuthUtils.getUid()).addListenerForSingleValueEvent(postListener)
    }

    private suspend fun checkExpiration(accessToken : String): BaseResponse<Boolean> {
        return RetrofitInstance.userApi.checkExpiration(accessToken)
    }

    private suspend fun reissueToken(postReissueReq : PostReissueReq): BaseResponse<String> {
        return RetrofitInstance.userApi.reissueToken(postReissueReq)
    }
}

④ 물론 위와 같이 SplashActivity를 작성해도 상관 없으나, 로그인할 때마다 access token의 만료 여부와 상관 없이 항상 재발급해주는 방식도 고려해볼 수 있다. 이 방식의 경우 사용상의 편리함은 있겠으나, 유효한 access token의 개수가 많아지고, refresh token을 자주 사용하게 되기 때문에 보안적 측면에서 좋은 방법은 아니다.

  • checkExpiration(accessToken)은 토큰의 유효성을 검증하기 위해 그대로 사용되었으나, 토큰의 만료 여부와 무관하게 항상 새로운 토큰을 발급해준다.
getAccessToken { accessToken -> // 액세스 토큰 이용
    if(accessToken == null) { // 회원가입만 하고, 로그인은 한번도 한 적 없는 유저 -> 로그인 페이지로 전환
        Handler().postDelayed({
            startActivity(Intent(this, LoginActivity::class.java))
            finish()
        }, 2000)
    }
    if (accessToken.isNotEmpty()) { // 일반적인 유저
        CoroutineScope(Dispatchers.IO).launch {
            val expirationChk = checkExpiration(accessToken) // 액세스 토큰의 만료 여부 확인
            if(expirationChk.isSuccess) { // 액세스 토큰의 만료 여부와 무관하게 항상 재발급
                getRefreshToken { refreshToken ->
                    if (refreshToken.isNotEmpty()) {
                        CoroutineScope(Dispatchers.IO).launch {
                            val postReissueReq = PostReissueReq(FirebaseAuthUtils.getUid(), refreshToken)
                            val response = reissueToken(postReissueReq) // 리프레시 토큰으로 액세스 토큰 재발급
                            if(response.isSuccess) {
                                val newAccessToken = response.result // 파이어베이스의 기존 accessToekn을 newAccessToken으로 바꿈
                                FirebaseRef.userInfo.child(uid).child("accessToken").setValue(newAccessToken)
                                // [만약 모든 API에 재발급 로직을 추가한다면, 여기에서 newAccessToken을 이용하여 API를 호출하면 된다]
                                withContext(Dispatchers.Main) {
                                    Handler().postDelayed({
                                        startActivity(Intent(this@SplashActivity, MainActivity::class.java))
                                        finish() }, 2000)
                                }
                            }
                            else { // 리프레시 토큰이 만료되었거나 유효하지 않은 경우 -> 로그인 화면으로 전환
                                withContext(Dispatchers.Main) {
                                    Toast.makeText(this@SplashActivity, "세션이 만료되어 로그인 후 이용 가능합니다", Toast.LENGTH_SHORT).show()
                                    val intent = Intent(this@SplashActivity, LoginActivity::class.java)
                                    startActivity(intent)
                                }
                            }
                        }
                    }
                    else { // access token은 Not Empty이면서, refresh token만 Empty 한 경우(거의 일어나지 않을 상황) -> 회원가입 페이지로 전환
                        Log.e("SplashActivity", "Invalid Token")
                        Handler().postDelayed({
                            startActivity(Intent(this@SplashActivity, IntroActivity::class.java))
                            finish() }, 2000)
                    }
                }
            }
            else { // 유효하지 않은 토큰(ex. 위변조된 토큰)의 접근 -> 회원가입 페이지로 전환
                Log.d("SplashActivity", "Invalid User")
                val message = expirationChk.message
                Log.d("SplashActivity", message)
                withContext(Dispatchers.Main) {
                    Toast.makeText(this@SplashActivity, message, Toast.LENGTH_SHORT).show()
                    Handler().postDelayed({
                        startActivity(Intent(this@SplashActivity, IntroActivity::class.java))
                        finish()
                    }, 2000)
                }
            }
        }
    }
    else {
        Handler().postDelayed({
            startActivity(Intent(this, LoginActivity::class.java))
            finish()
        }, 2000)
    }
}

여러가지 방법을 소개하려다보니 괜히 혼란만 더 가중되었을거 같아 마지막으로 한번만 더 이야기하겠다. 실제 프로젝트나 런칭을 생각한다면, 당연히 모든 API에 대해 access token의 만료를 평가한 후에 access token을 재발급해야 한다.

이제 코드를 실행해보자. access token이 만료되어도(또는 만료되지 않아도) 새로운 access token을 발급 받는 것을 파이어베이스에서 확인할 수 있을 것이다.

이로써, 인증 정보는 Client가 저장(Stateful)하고, Server는 유저의 로그인 사실조차 기억할 필요가 없는 Stateless 상태가 되었다. 이와 같이 JWT 인증방식을 사용하는 서버는 Stateless를 지향해야 한다.

profile
Java Spring, Android Kotlin, Node.js, ML/DL 개발을 공부하는 인하대학교 정보통신공학과 학생입니다.

0개의 댓글