[클론 코딩] 네이버 카페 - 네이버 로그인

2024. 5. 10. 19:07Project/Naver Cafe

  이번에는 저장되어있는 회원정보를 토대로 사용자가 로그인할 수 있도록하는 기능을 추가하고자 한다.


Member

package CloneCoding.NaverCafe.domain.member;

@Entity
@Table(name = "MEMBER")
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ID")
    private Long id;

    @Column(name = "ACCOUNT_ID", unique = true)
    private String accountId;

    @Column(name = "ACCOUNT_PASSWORD")
    private String accountPassword;

    @Column(name = "EMAIL", unique = true)
    private String email;

    @Column(name = "USERNAME")
    private String username;

    @Column(name = "BIRTHDAY")
    private LocalDate birthday;

    @Column(name = "PHONE_NUMBER", unique = true)
    private String phoneNumber;

    @Column(name = "NICKNAME", unique = true)
    private String nickname;

    @Column(name = "STATUS")
    @Builder.Default
    private String status = STATUS_LOGOUT.getStatus();

    public void setLoginStatus(String status) {
        this.status = status;
    }

}

 

  우선 회원 로그인 상태(status)를 Member 클래스에 추가했다. @Builder를 사용해 객체 생성시 초기화 값을 가지게 설정했으며 기본 값은 "logout"이다. 회원이 로그인하면 status가 "login"으로 변경된다.


Login

package CloneCoding.NaverCafe.message;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Login {

    STATUS_LOGIN("login"),
    STATUS_LOGOUT("logout")
    ;

    private final String status;

}

 

  로그인 기능에 사용되는 상수들을 지정한 Enum 클래스. 로그인 상태로 사용되는 STATUS_LOGIN과 STATUS_LOGOUT이 있다. 문자열을 직접 지정하여도 되나 추후 변경될 수 있는 부분이므로 유지보수에 용이하도록 위와 같이 사용하게 되었다.


SystemMessage

package CloneCoding.NaverCafe.message;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum SystemMessage {

    JOIN_COMPLETE_NAVER("정상적으로 회원가입을 완료했습니다!" +
            System.lineSeparator() +
            "로그인 페이지로 이동해 로그인 해주세요."),
    LOGIN_COMPLETE("정상적으로 로그인이 완료되었습니다!")
    ;

    private final String message;

}

 

  시스템 메시지를 상수로 갖는 Enum 클래스로, 로그인 결과를 사용자에게 알릴 수 있도록 시스템 메시지(LOGIN_COMPLETE)를 추가했다.


PasswordKey

package CloneCoding.NaverCafe.security;

import lombok.Getter;
import org.springframework.stereotype.Component;

@Getter
@Component
public class PasswordKey {
    // 암호키 : 32비트
    public final String passwordKey = "AES_PRIVATE_KEY_THIS_TEST_32BYTE";
}

 

  토큰 암호화에 사용될 암호키를 가지는 클래스이다. AES 암호화 사용시 암호키는 16비트(AES128), 24비트(AES192), 32비트(AES256) 중 하나를 사용할 수 있으며 그 외에는 오류가 발생한다. 해당 프로젝트에는 32비트의 암호키를 사용하였다.


AesUtil

package CloneCoding.NaverCafe.security;

import lombok.RequiredArgsConstructor;
import org.apache.tomcat.util.codec.binary.Base64;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;

@RequiredArgsConstructor
public class AesUtil {

    private final String passwordKey;

    public String aesEncode(String plainText) {

        SecretKeySpec secretKey = new SecretKeySpec(passwordKey.getBytes(StandardCharsets.UTF_8), "AES");
        IvParameterSpec ivParameter = new IvParameterSpec(passwordKey.substring(0, 16).getBytes());

        try {
            Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
            c.init(Cipher.ENCRYPT_MODE, secretKey, ivParameter);

            byte[] encodeByte = c.doFinal(plainText.getBytes(StandardCharsets.UTF_8));

            return Base64.encodeBase64String(encodeByte);
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
                 InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
            throw new RuntimeException(e);
        }

    }

    public String aesDecode(String encodeText) {

        SecretKeySpec secretKey = new SecretKeySpec(passwordKey.getBytes(StandardCharsets.UTF_8), "AES");
        IvParameterSpec ivParameter = new IvParameterSpec(passwordKey.substring(0, 16).getBytes());

        try {
            Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
            c.init(Cipher.DECRYPT_MODE, secretKey, ivParameter);

            byte[] decodeByte = c.doFinal(Base64.decodeBase64(encodeText));

            return new String(decodeByte, StandardCharsets.UTF_8);
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
                 InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
            throw new RuntimeException(e);
        }

    }

}

 

  토큰 암호화/복호화에는 AES(Advanced Encryption Standard)-256 알고리즘을 사용하기 위해 javax.crypto 패키지를 사용했으며, AesUtil 클래스는 해당 알고리즘을 사용해 사용자의 계정 ID를 암복호화하는 기능을 가지며, PasswordKey 클래스를 주입 받는다.


MemberController

package CloneCoding.NaverCafe.domain.member.controller;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {

    private final MemberService memberService;

    @PostMapping("/login")
    public ResponseLogin login(@RequestBody RequestLogin request) {
        log.info("로그인 요청");
        return memberService.login(request);
    }

}

 

  사용자가 계정 ID와 비밀번호 정보와 함께 로그인 요청을 할 수 있도록 login()를 추가.


MemberService, MemberServiceImpl

// MemberService
package CloneCoding.NaverCafe.domain.member.service;

public interface MemberService {

    ResponseLogin login(RequestLogin request) ;

}


// MemberServiceImpl
package CloneCoding.NaverCafe.domain.member.service;

import CloneCoding.NaverCafe.security.AesUtil;

@Service
@Transactional
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;
    private final AesUtil aesUtil;

    @Override
    public ResponseLogin login(RequestLogin request) {

        Member findMember = memberRepository.findByAccount(request);
        findMember.setLoginStatus(STATUS_LOGIN.getStatus());
        memberRepository.save(findMember);
        
        String token = aesUtil.aesEncode(findMember.getAccountId());

        return new ResponseLogin(token);

    }

}

 

  MemberServiceImpl.login()은 사용자의 로그인 정보를 받아 정보와 일치하는 회원을 조회한 후, 로그인 상태를 "login"으로 변경, 최종적으로 변경된 회원 정보를 DB에 반영한다. 이후에 토큰(token)을 생성, 응답용 DTO에 token 정보를 담아 객체를 생성 후 반환한다.


QueryMemberRepository, QueryMemberRepositoryImpl

// QueryMemberRepository
package CloneCoding.NaverCafe.domain.member.repository;

public interface QueryMemberRepository {

    Member findByAccount(RequestLogin request);

}


// QueryMemberRepositoryImpl
package CloneCoding.NaverCafe.domain.member.repository;

@Repository
@RequiredArgsConstructor
public class QueryMemberRepositoryImpl implements QueryMemberRepository {

    private final JPAQueryFactory query;

    @Override
    public Member findByAccount(RequestLogin request) {
        return Optional.ofNullable(query
                        .selectFrom(member)
                        .where(member.accountId.eq(request.getAccountId()), member.accountPassword.eq(request.getAccountPassword()))
                        .fetchOne())
                .orElseThrow(() -> new NoSuchElementException("아이디 또는 비밀번호가 올바르지 않습니다."));
    }

}

 

  MemberRepositoryImpl.findByAccount()는 사용자의 계정 정보를 통해 회원 정보를 조회하는 메서드이다. 사용자의 계정 ID와 비밀번호, 두 정보가 모두 일치하는 회원 정보를 찾아 Member 객체로 반환한다.


RequestLogin, ResponseLogin

// RequestLogin
package CloneCoding.NaverCafe.domain.member.dto;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RequestLogin {

    private String accountId;
    private String accountPassword;

}


// ResponseLogin
package CloneCoding.NaverCafe.domain.member.dto;

@Getter
public class ResponseLogin {

    public ResponseLogin() {
    }

    public ResponseLogin(String token) {
        this.token = token;
    }

    private final String message = SystemMessage.LOGIN_COMPLETE.getMessage();

    private String token;

}

 

  로그인 요청 정보를 전달(RequestLogin)하고 로그인 결과 정보(ResponseLogin)를 전달하는 DTO 클래스들이다. 로그인 요청시 사용자는 계정 ID와 비밀번호 정보를 서버에 전달하며, 서버는 사용자에게 로그인 결과 메시지와 함께 토큰(token) 정보를 전달하게 된다.


테스트 코드

package CloneCoding.NaverCafe.domain.member.service;

@Transactional
@SpringBootTest
class MemberServiceImplTest {

    @Autowired
    AesUtil aesUtil;

    @DisplayName("암호화 복호화 테스트")
    @Test
    void AesUtilTest() {
        // given
        String accountId = "springboot";

        // when
        String encodeData = aesUtil.aesEncode(accountId);
        String decodeData = aesUtil.aesDecode(encodeData);

        // then
        System.out.println("inputData : " + accountId);
        System.out.println("encodeData : " + encodeData);
        System.out.println("decodeData : " + decodeData);
        assertThat(accountId).isEqualTo(decodeData);

    }

}

 

  간단하게 암/복호화가 잘 되는지 확인하는 테스트 코드를 작성, 입력 데이터와 복호화 데이터가 같음을 확인할 수 있었다.

테스트 코드 결과 - 암복호화


API TEST

API TEST 결과 - 로그인

 

DB - 네이버 로그인

 

  DB에 저장된 회원의 계정 ID와 비밀번호로 로그인을 시도하니 정상적으로 잘 되는 걸 확인할 수 있으며, DB에도 STATUS 값이 잘 반영된 걸 확인할 수 있다.