본문 바로가기

[개인프로젝트] 개발 공부

[프로젝트] 7. Spring Security + JWT로 로그인 구현하기 (3)

728x90
반응형

앞서 Spring Security와 JWT 관련 설정들을 모두 끝냈다면 이제 관련 로직들에 기능들을 적용하여 사용해 보겠다.

 

로그인 시 JWT 토큰을 발급해 줄 것이고 페이지 새로고침 및 로그인 연장 시 토큰 재발급을 하게 해 줄 것이다.

페이지 새로고침 시 재발급을 해주는 이유는 이제 AccessToken은 RequestHeader에 담겨서 서버로 보내지게 되는데 이때 새로고침 이벤트가 발생하게 되면 헤더에서 인증 토큰이 사라지게 된다. 그래서 새로고침을 하면 즉시 토큰을 새로 재발급해서 헤더에 넣어주는 방식으로 구현하였다.

 

우선 로그인 시 JWT 토큰을 발급하는 로직을 작성해 보자.

// MemberAuthService.java
@RequiredArgsConstructor
@Service
public class MemberAuthService {

    private final MemberRepository memberRepository;
    private final MemberLogRepository memberLogRepository;
    private final MemberTermsRepository memberTermsRepository;
    private final RefreshTokenRepository refreshTokenRepository;

    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;

    @Transactional
    public CommonResponseDto<?> signUp(MemberSignUpRequestDto requestDto) {
        try {
            Member member = memberRepository.save(requestDto.toMember(passwordEncoder));

            requestDto.getMemberTermsAgree().setMember(member);
            memberTermsRepository.save(requestDto.getMemberTermsAgree().toMemberTerms());
        } catch (Exception e) {
            return CommonResponseDto.setFailed("Data Base Error!");
        }
        return CommonResponseDto.setSuccess("Sign Up Success", null);
    }

    @Transactional
    public CommonResponseDto<JwtTokenResponseDto> signIn(MemberSignInRequestDto requestDto) {

        String memberId = requestDto.getMemberId();
        String memberPw = requestDto.getMemberPw();
        JwtTokenResponseDto tokenDto = new JwtTokenResponseDto();

        try {
            Member member = memberRepository.findByMemberId(memberId)
                    .orElseThrow(() -> new IllegalArgumentException("해당 사용자 아이디가 없습니다. ID : " + memberId));
            MemberLog memberLog;

            if(member.getMemberWithdrawYn().equals("Y")) {
                return CommonResponseDto.setFailed("탈퇴된 사용자 입니다.");
            } else {
                boolean matchPassword = passwordEncoder.matches(memberPw, member.getMemberPw());

                if(!matchPassword) {
                    memberLog = MemberLog.builder()
                            .member(member)
                            .memberLogType("COMMON")
                            .memberLogSuccessYn("N")
                            .memberLogReason("비밀번호 인증 실패")
                            .memberLogIpAddress("")
                            .build();

                    memberLogRepository.save(memberLog);

                    return CommonResponseDto.setFailed("비밀번호가 일치하지 않습니다.");
                } else {
                    // 1. Login ID/PW 를 기반으로 AuthenticationToken 생성
                    UsernamePasswordAuthenticationToken authenticationToken = requestDto.toAuthentication();

                    // 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분
                    //    authenticate 메서드가 실행이 될 때 CustomUserDetailsService 에서 만들었던 loadUserByUsername 메서드가 실행됨
                    Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

                    if(authentication.isAuthenticated()) {
                        memberLog = MemberLog.builder()
                                .member(member)
                                .memberLogType("COMMON")
                                .memberLogSuccessYn("Y")
                                .memberLogReason("JWT 인증 성공")
                                .memberLogIpAddress("")
                                .build();

                        memberLogRepository.save(memberLog);

                        // 3. 인증 정보를 기반으로 JWT 토큰 생성
                        tokenDto = jwtTokenProvider.generateTokenDto(authentication, "COMMON");

                        // 4. RefreshToken 저장
                        RefreshToken refreshToken = RefreshToken.builder()
                                .key(authentication.getName())
                                .value(tokenDto.getRefreshToken())
                                .build();
                        refreshTokenRepository.save(refreshToken);
                    } else {
                        memberLog = MemberLog.builder()
                                .member(member)
                                .memberLogType("COMMON")
                                .memberLogSuccessYn("N")
                                .memberLogReason("JWT 인증 실패")
                                .memberLogIpAddress("")
                                .build();

                        memberLogRepository.save(memberLog);
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
            return CommonResponseDto.setFailed("가입된 사용자가 아닙니다.");
        }
        return CommonResponseDto.setSuccess("Sign In Success", tokenDto);
    }
}

 

회원가입 같은 경우는 앞쪽에서 입력한 값들을 전달해 주고 비밀번호 부분만 passwordEncoder.encode()를 사용하여 암호화하여 DB에 넣어주기만 하면 된다.

 

로그인 부분은 사용자 아이디가 존재하는지 탈퇴한 사용자인지 비밀번호는 일치한 지 분기처리를 해주고 그에 맞게 메시지를 다시 클라이언트로 보내주거나 로그인 로그에 데이터를 추가해 주는 식으로 구현하였다.

 

requestDto.toAuthentication(); 부분은 이제 관련 DTO에서 아래와 같은 코드를 가져온 것이다.

public UsernamePasswordAuthenticationToken toAuthentication() {
    return new UsernamePasswordAuthenticationToken(memberId, memberPw);
}

 

AuthenticationToken 생성에 입력한 아이디와 비밀번호를 넣어주고 이 토큰을 통해 유효한 아이디와 비밀번호인지 검증이 이루어진다. 애초에 비밀번호가 틀리다면 이전 부분에서 처리가 되지만 보다 확실하게 검증을 하고 authentication 생성을 위해 위와 같은 로직이 필요하다.

 

authentication이 성공적으로 생성이 되면 이제 JWT 토큰을 생성해 준다. 토큰 생성 로직은 이전 글에서 확인할 수 있다.

생성된 토큰은 AccessToken과 RefreshToken으로 나뉘는데 AccessToken은 헤더에 담겨 서버와의 통신에서 인증으로 사용될 토큰이고 RefreshToken은 만료된 AccessToken 재발급을 위해 쿠키에 저장이 되어 사용이 된다.

이때 재발급 절차에서 RefreshToken이 유효하고 유저 정보와 일치한 지 확실한 체크를 위해 따로 DB에 저장을 해준다.

 

재발급 관련 로직은 아래와 같다.

// MemberAuthService.java
@Transactional
public CommonResponseDto<JwtTokenResponseDto> reissue(JwtTokenRequestDto requestDto) {
    try {
        // Refresh Token 검증
        if (!jwtTokenProvider.validateToken(requestDto.getRefreshToken())) {
            return CommonResponseDto.setFailed("Refresh Token 이 유효하지 않습니다.");
        } else {
            // Refresh Token 에서 MemberNo 가져오기
            Authentication authentication = jwtTokenProvider.getAuthentication(requestDto.getRefreshToken());

            // 저장소에서 MemberNo를 기반으로 Refresh Token 값 가져옴
            RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName())
                    .orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));

            // Refresh Token 일치하는지 검사
            if (!refreshToken.getValue().equals(requestDto.getRefreshToken())) {
                return CommonResponseDto.setFailed("토큰의 유저 정보가 일치하지 않습니다.");
            } else {
                JwtTokenResponseDto tokenDto = jwtTokenProvider.generateTokenDto(authentication, "COMMON");

                // 저장소 정보 업데이트
                RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
                refreshTokenRepository.save(newRefreshToken);
                return CommonResponseDto.setSuccess("Reissue Success", tokenDto);
            }
        }
    } catch (Exception e) {
        return CommonResponseDto.setFailed("Data Base Error!!!");
    }
}

 

새로고침 및 재발급을 위한 이벤트가 발생하면 reissue 로직이 동작하게 되고 쿠키에 담겨 있던 RefreshToken과 DB에 저장해 두었던 RefreshToken을 비교하여 유효한지 체크를 한다.

일치한 지 확인이 되었다면 다시 AccessToken과 RefreshToken을 발급하고 새로 발급된  AccessToken은 클라이언트로 RefreshToken은 DB에 새로 업데이트되고 쿠키에 넣어준다.

 

728x90
반응형

 

프론트 쪽 코드는 아래와 같다.

// SignIn.tsx
const signInHandler = ():void => {
        const signInData:object = {
            memberId: loginMemberId,
            memberPw: loginMemberPw
        }

        if(loginMemberId.length < 1) {
            alert('아이디를 입력해주세요.');
        } else if(loginMemberPw.length < 1) {
            alert('비밀번호를 입력해주세요.');
        } else {
            axios({
                method: "POST",
                url: "/member/signIn",
                data: JSON.stringify(signInData),
                headers: {'Content-type': 'application/json'}
            }).then((res) => {
                const responseData = res.data;
                if(responseData.data) {
                    const { grantType, accessToken, refreshToken, accessTokenExpires, accessTokenExpiresDate} = responseData.data;
                    setIsTokenExpiresTimeStart(true);
                    setTokenExpiresTime(accessTokenExpires);
                    const expiresDate:Date = new Date(accessTokenExpiresDate);

                    axios.defaults.headers.common['Authorization'] = `${grantType} ${accessToken}`;

                    setCookie('refreshToken', refreshToken, {
                        path: '/',
                        httpOnly: true,
                        secure: true,
                        expires: expiresDate
                    });
                } else {
                    alert(responseData.message);
                }
            }).catch((err) => {
                const errCode:string = err.message.substring(err.message.length-3);

                if(errCode === '401' || errCode === '403') { // 대부분 access token 만료로 인한 오류
                    alert('새로고침을 한번 해주세요');
                }
            })
        }
    }

 

로그인이 성공적으로 진행이 되면 axios.defaults.headers.common을 통해 RequestHeader에 저장을 해주고  setCookie를 통해 RefreshToken을 쿠키에 저장해 준다.

 

재발급과 관련된 프론트 쪽 코드는 아래와 같으며 따로 ts로 빼주어서 필요한 곳에 사용해주고 있다.

// reissue.ts
const reissue = async ():Promise<number> => {
    if(getCookie('refreshToken')) {
        const token:object = {
            accessToken: axios.defaults.headers.common["Authorization"]?.toString(),
            refreshToken: getCookie('refreshToken')
        }
        return await axios({
            method: "POST",
            url: "/member/reissue",
            data: JSON.stringify(token),
            headers: {'Content-type': 'application/json'}
        }).then((res):number|any => {
            const responseData = res.data;
            if(responseData.result) {
                const { grantType, accessToken, refreshToken, accessTokenExpires, accessTokenExpiresDate} = responseData.data;
                const expiresDate:Date = new Date(accessTokenExpiresDate);

                axios.defaults.headers.common['Authorization'] = `${grantType} ${accessToken}`;

                setCookie('refreshToken', refreshToken, {
                    path: '/',
                    httpOnly: true,
                    secure: true,
                    expires: expiresDate
                });
                return accessTokenExpires;
            } 
        }).catch((err) => {
            const errCode:string = err.message.substring(err.message.length-3);

            if(errCode === '401' || errCode === '403') { // 대부분 refresh token 만료로 인한 오류
                alert('재로그인을 해주세요');
            }
        })
    } else {
        return 0;
    }

}

 

다음으로 OAuth2를 사용하여 소셜로그인을 구현하고 일반로그인과 똑같이 JWT 토큰을 발급해 주도록 할 것이다.

 

관련 포스팅

 

2024.03.01 - [[개인프로젝트] 개발 공부] - [프로젝트] 5. Spring Security + JWT로 로그인 구현하기 (1)

 

[프로젝트] 5. Spring Security + JWT로 로그인 구현하기 (1)

로그인, 회원가입 개발을 하다 보면 제일 많이 접근하고 자주 구현하게 되는 기능이 아닐까 싶다.예전 막 개발을 시작했을 때는 아무런 기능 없이 회원가입 때 아이디와 비밀번호를 DB에 넣어주

rlawo32.tistory.com

 

2024.03.06 - [[개인프로젝트] 개발 공부] - [프로젝트] 6. Spring Security + JWT로 로그인 구현하기 (2)

 

[프로젝트] 6. Spring Security + JWT로 로그인 구현하기 (2)

이전에 Spring Security 설정을 대략적으로 끝냈다면 이제 JWT를 설정해 보겠다. 우선 JWT dependencies를 작성해 주자 // build.gradle plugins { id 'java' id 'org.springframework.boot' version '3.0.4' id 'io.spring.dependency-manag

rlawo32.tistory.com

 

2024.03.18 - [[개인프로젝트] 개발 공부] - [프로젝트] 8. Spring Security + OAuth2.0 (feat. JWT)로 간편 로그인 구현하기 (1)

 

[프로젝트] 8. Spring Security + OAuth2.0 (feat. JWT)로 간편 로그인 구현하기 (1)

이전에 Spring Security + JWT를 이용해 로그인을 구현해 보았다. 이제 여기에 추가로 간편 로그인 기능도 구현해 보자. 간편 로그인은 사실상 어느 웹사이트를 가든 공통적으로 존재하는 요소이다.

rlawo32.tistory.com

 

2024.03.21 - [[개인프로젝트] 개발 공부] - [프로젝트] 9. Spring Security + OAuth2.0 (feat. JWT)로 간편 로그인 구현하기 (2)

 

[프로젝트] 9. Spring Security + OAuth2.0 (feat. JWT)로 간편 로그인 구현하기 (2)

이제 본격적으로 구현을 해보자 우선 각 플랫폼 별로 로그인 페이지를 호출하는 코드는 아래와 같다. // naver "http://localhost:8080/oauth2/authorization/naver"}>네이버 // kakao "http://localhost:8080/oauth2/authorizati

rlawo32.tistory.com

 

 

 

728x90
반응형