Spring 프로젝트 시작하기 - 로그인편

로그인은 간단하게 ID, PW를 확인하고 OK 해주면 될것같지만 생각보다 처리해주어야할부분이 많다.

큰산 하나인 토큰을 발급하는부분은 이전에 다루었으니 제외하고 설명해보겠다.

 

AuthController

@Slf4j
@RequestMapping("/auth")
@RestController
@RequiredArgsConstructor
public class AuthController {

    private final JwtProvider jwtProvider;
    private final AuthService authService;

    /**
     * 로그인
     *
     * @return 멤버 리스트
     * GET /auth/login
     */
    @ExcludeAuth
    @PostMapping("/login")
    public ResponseEntity<ServerResponse> login(HttpServletRequest request, @RequestBody AuthLoginRequest param, HttpServletResponse response) {
        log.debug("Controller [{}] 진입", Thread.currentThread().getStackTrace()[1].getMethodName());
        JwtTokenDto tokenUSer = authService.login(request, param);

        // 로그인 토큰 생성
        jwtProvider.createAccessTokenString(tokenUSer, response);
        jwtProvider.createRefreshTokenString(tokenUSer, response);

        return ResponseEntity.ok(ServerResponse.SUCCESS);
    }
}

이전에 만들었던 로그인 Controller이다.

 

AuthLoginRequest

@Data
@NoArgsConstructor
public class AuthLoginRequest {
    String id;
    String pw;

    @Override
    public String toString() {
        return ToStringBuilder.reflectionToString(this, ToStringStyle.JSON_STYLE)
                .replaceAll(pw, "******");
    }
}

Request Body로 받은 DTO는 이렇다.

받은값을 로그로 출력할때 혹시라도 PW가 노출되지 않도록 마스킹처리를 했다.

 

AuthService

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

    private final MemberRepository memberRepository;
    private final UserLoginHistoryRepository userLoginHistoryRepository;


    public JwtTokenDto login(HttpServletRequest request, AuthLoginRequest param) {
        log.debug("Service [{}] 진입", Thread.currentThread().getStackTrace()[1].getMethodName());

        // ID로 유저정보 조회
        MemberEntity userData = memberRepository.findMemberEntityByUserId(param.getId());
        if (userData == null) {
            this.createUserLoginHistory(request, userData, false);
            throw new BadRequestException(ServerResponse.INVALID_LOGIN);
        }
        // Parameter로 받은 user PW 암호화
        String hashPw = CryptUtil.genCryptoHash(param.getId(), param.getPw());

        // Password가 다를때에 로그인 에러 발생
        if (!hashPw.equals(userData.getUserPw())) {
            this.createUserLoginHistory(request, userData, false);
            throw new BadRequestException(ServerResponse.INVALID_LOGIN);
        }

        // 로그인 히스토리 추가
        this.createUserLoginHistory(request, userData, true);

        return JwtTokenDto.builder()
                .cIdx(userData.getCustomerIdx())
                .uIdx(userData.getUserIdx())
                .uId(userData.getUserId())
                .build();
    }
    
    ... 생략 ...
}

1. param으로 받은 유저정보를 검색한다. 유저가 없으면 Exception을 리턴한다.

2. param으로 받은 PW를 암호화한다.

SHA512 알고리즘은 단방향 해싱으로, 복호화가 불가능하다.

유저생성시에 암호화된 PW값을 저장하고 로그인시에도 PW를 암호화시켜 값을 비교한다.

3. 로그인 히스토리 데이터를 생성한다.

4. 차후 Token을 생성할 유저데이터를 리턴한다.

 

CryptUtil

@Slf4j
public class CryptUtil {
    /**
     * SHA-256 해시 생성
     * @param password
     * @return 해시값
     */
    public static String genCryptoHash(String id, String password) {
        final String ALGORITHM = "PBKDF2WithHmacSHA512";
        final String key = id+"passwordHash";
        final int KEY_LENGTH = 32;
        final int rounds = 30000;

        KeySpec spec = new PBEKeySpec(password.toCharArray(), key.getBytes(), rounds, KEY_LENGTH * 8);
        try {
            SecretKeyFactory factory = SecretKeyFactory.getInstance(ALGORITHM);
            byte[] hash = factory.generateSecret(spec).getEncoded();
            return bytesToHex(hash);
        } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
            e.printStackTrace();
        }
        return null;
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder result = new StringBuilder();
        for (byte b : bytes) {
            result.append(String.format("%02x", b));
        }
        return result.toString();
    }
}

암호화 로직이다.

알고리즘 방식은 PBKDF2WithHmacSHA512이다.

암호 키값은 유저마다 다르게 설정해주려고 ID를 넣어서 키값을 만들었다.

길이는 32이며,

30000회 반복해서 무차별 대입공격을 방어하고자 했다.

 

유저 생성 기능이 아직 구현이 안되었다면, 슬프지만 암호화된 값을 로그로 뽑아서 DB에 손수 넣어주면 된다.

 

❗❗ 주의사항
개발단계에서는 암호화 로직을 맘대로 수정해도 되지만,
사용자에게 넘어간 운영시점에서는 절대 로직이 바뀌면 안된다.

당연하게 키값, round값, length중 하나라도 바뀌면 결과값이 바뀌게 되고,
원본 password가 뭔지 알수 없기때문에 시스템 복구가 어렵다.

 

AuthService

    /**
     * 유저 로그인 기록 생성
     *
     * @param request
     * @param historyUser
     * @param successYn
     */
    public void createUserLoginHistory(HttpServletRequest request, MemberEntity historyUser, boolean successYn) {

        UserLoginHistoryEntity userLoginHistoryEntity = UserLoginHistoryEntity.builder()
                .uIdx(historyUser.getUserIdx())
                .cIdx(historyUser.getCustomerIdx())
                .uhAction("LOGIN")
                .uhActionText(successYn ? "Login 성공" : "Login 실패")
                .uhLocation(request.getHeader("X-Forwarded-For"))
                .uhAddr(request.getRemoteAddr())
                .uhAgent(request.getHeader("User-Agent"))
                .uhSuccessYn(successYn ? CommonEnum.Y : CommonEnum.N)
                .uhAccessDt(CommonUtil.getUnixNow())
                .build();

        userLoginHistoryRepository.save(userLoginHistoryEntity);
    }

다시 auth service로 돌아와서,

로그인 기록을 생성하는 함수를 만들었다.

파라미터는 HttpServletRequest를 받아서 클라이언트의 IP, address, agent정보를 추출해서 저장했다.

MemberEntity에서는 User의 인덱스 정보를 넣었고,

마지막으로 boolean값을 받아 성공여부를 저장했다.