[회원관리] 로그인

2023. 1. 20. 14:16프로젝트/라이프 챌린지

728x90
SMALL

LoginRequestDto

import lombok.Getter;

@Getter
public class LoginRequestDto {
    private String member_id;
    private String password;
}

회원가입과 동일하게 Service단에 @RequestBody 로 Dto 객체로 받아 넘기기 위해 Dto 객체를 생성한다.

로그인하기 위해 입력받은 아이디와 비밀번호가 들어갈 것이다.

 

 

 

MemberController

// 로그인
@PostMapping("/login")
public ResponseEntity<ResponseBody> memberLogin(HttpServletResponse response, @RequestBody LoginRequestDto loginRequestDto){
    log.info("로그인 - 아이디 : {}, 비밀번호 : {}", loginRequestDto.getMember_id(), loginRequestDto.getPassword());

    return memberService.memberLogin(response, loginRequestDto);
}

 

MemberController에 로그인 api를 만든다.

  • 로그인이 정상적으로 완료가 되면 HttpResponse 헤더에 생성된 토큰을 넣어 넘겨주기 위해 HttpServletResponse 를 인자값으로 받는다.
  • 위에서 만든 LoginRequestDto를 인자값으로 받는다.

 

 

MemberService

// 로그인
@Transactional
public ResponseEntity<ResponseBody> memberLogin(HttpServletResponse response, LoginRequestDto loginRequestDto){

    log.info("로그인 서비스 진입");

    // 계정이 존재하지 않는지 아이디 기준으로 확인
    if(queryFactory
            .selectFrom(member)
            .where(member.member_id.eq(loginRequestDto.getMember_id()))
            .fetchOne() == null){

        return new ResponseEntity<>(new ResponseBody(StatusCode.NOT_EXIST_ACCOUNT.getStatusCode(), StatusCode.NOT_EXIST_ACCOUNT.getStatus(), null), HttpStatus.BAD_REQUEST);
    }

    log.info("로그인할 계정 존재");

    // 아이디가 일치하는 계정이 있다면 해당 계정 불러오기
    Member exist_member = queryFactory
            .selectFrom(member)
            .where(member.member_id.eq(loginRequestDto.getMember_id()))
            .fetchOne();
            
    // 이미 로그인한 계정이라면 존재했던 토큰 삭제
    if(queryFactory
            .selectFrom(token)
            .where(token.member_id.eq(exist_member.getMember_id()))
            .fetchOne() != null){

        // 존재했던 토큰 삭제
        queryFactory
                .delete(token)
                .where(token.member_id.eq(exist_member.getMember_id()))
                .execute();
    }

    log.info("존재하는 계정 조회");

    // 불러온 계정의 비밀번호와 입력받은 비밀번호가 일치하는지 확인
    if(!passwordEncoder.matches(loginRequestDto.getPassword(), exist_member.getPassword())){
        return new ResponseEntity<>(new ResponseBody(StatusCode.INCORRECT_PASSWORD.getStatusCode(), StatusCode.INCORRECT_PASSWORD.getStatus(), null), HttpStatus.BAD_REQUEST);
    }

    log.info("DB에 존재하는 계정과 로그인 요청한 비밀번호 일치 확인");

    // 1. Login ID/PW 를 기반으로 Authentication 객체 생성
    // 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginRequestDto.getMember_id(), loginRequestDto.getPassword());

    log.info("UsernamePasswordAuthenticationToken 생성되었음을 확인 - {}", authenticationToken);

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

    log.info("Authentication 생성되었음을 확인 - {}", authentication);

    // 3. 인증 정보를 기반으로 JWT 토큰 생성 (우선 Dto로 생성)
    TokenDto tokenDto = jwtTokenProvider.generateToken(authentication);

    log.info("TokenDto가 생성되었음을 확인 - {}", tokenDto.getAccessToken());

    // 4. tokenDto에 토큰 정보가 옳바르게 대입되고 난 후에 Response 헤더에 필요한 토큰 정보들을 추가하여 반환
    response.addHeader("Authorization", "Bearer " + tokenDto.getAccessToken());
    response.addHeader("Refresh-Token", tokenDto.getRefreshToken());
    response.addHeader("Access-Token-Expire-Time", tokenDto.getAccessTokenExpiresIn().toString());

    log.info("Response에 헤더가 추가되고 값이 존재함을 확인 - {}", response.getHeader("Authorization"));

    // 5. Dto로 생성된 token 정보들을 Token 엔티티에 build
    Token token = Token.builder()
            .accessToken(tokenDto.getAccessToken())
            .refreshToken(tokenDto.getRefreshToken())
            .grantType(tokenDto.getGrantType())
            .member_id(exist_member.getMember_id())
            .build();

    log.info("Token에 값이 대입되었음을 확인 - {}", token.getAccessToken());

    // 6. 토큰 저장
    tokenRepository.save(token);

    HashMap<String, String> tokenSet = new HashMap();

    tokenSet.put("grantType", token.getGrantType());
    tokenSet.put("Authoriaztion", token.getGrantType() + " " + token.getAccessToken());
    tokenSet.put("accessToken", token.getAccessToken());
    tokenSet.put("refreshToken", token.getRefreshToken());

    return new ResponseEntity<>(new ResponseBody(StatusCode.OK.getStatusCode(), StatusCode.OK.getStatus(),  tokenSet), HttpStatus.OK);
}

Controller에서 넘겨받은 인자값들을 가지고 Service단에서 처리할 로직을 작성한다.

  • 제일 처음 Dto로 넘겨받은 아이디값을 가지고 querydsl을 사용하여 해당 아이디를 가진 계정이 존재하는 지 확인.
  • 존재한다면 해당 Member 계정 조회 후 불러오기
  • 이미 로그인한 계정이라면 다시 로그인 시도할 때 이전의 토큰은 삭제하도록 한다.
  • DB에는 비밀번호가 Encoding 되어 raw비밀번호 값 그대로 로그인을 하려고 하면 안되는것이 정상이다. 따라서, PasswordEncoder의 matches 함수로 DB에 저장된 Encoding 된 비밀번호와 입력받은 비밀번호가 일치하는지 확인한다.
  • 비밀번호 검증이 통과가 되었다면, UsernamePasswordAuthenticationToken에 입력받은 로그인 아이디와 비밀번호를 넘겨 Authentication 객체를 생성한다. UsernamePasswordAuthenticationToken은 Authentication 인터페이스를 상속받고 있기 때문에 Authentication 객체를 생성할 수 있다.
  • 생성한 authenticationToken으로 사용자 비밀번호 체크와 같은 실질적인 검증을 하는  부분인 authenticationManagerBuilder의 authenticate 함수를 통해 다시 Authentication 객체를 만든다.
  • 이 때, UserDetailsServiceImpl 에서 구현한 loadUserByUsername 메소드가 실행되면서 사용자 검증을 한다.
  • 사용자 검증이 완료되고 난 이후에, 생성된 Authentication 객체를 활용하여 TokenProvider 클래스에 있는 generateToken 메소드를 실행하여 token을 생성한다.
  • 생성된 token은 Token 엔티티에 바로 적용되어 저장하는 것이 아니라 TokenDto라는 중간 저장 지점을 만들어준다.
  • TokenDto 에 정상적으로 중간 저장되었다면 우선 HttpResponse 헤더에 생성된 token들의 정보를 넣어준다.
    • "Authorization" : AccessToken
    • "Refresh-Token" : RefreshToken
    • "Access-Token-Expire-Time" : AccessToken 만료 기간
  • Response 헤더에 저장이 완료되고 나면 해당 Token들을 엔티티에 저장한다. 어떤 계정인지 아이디도 같이 저장한다.
  • 반환값은 HashMap으로 원하는 양식의 데이터를 얻고 싶었기 때문에 개인적으로 따로 만들어서 반환받았다.

코드 내부에 많이 기입된 log.info는 처음에 구현했을 당시에 에러가 나지 않았지만 아예 결과값이 안뜨는 이슈가 발생하여 확인하기 위해 많이 기입하였다.

 

 

 

UserDetailsServiceImpl

import com.example.lifechallenge.domain.Member;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import static com.example.lifechallenge.domain.QMember.member;

@Slf4j
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final JPAQueryFactory queryFactory;

    @Override
    public UserDetails loadUserByUsername(String member_id) throws UsernameNotFoundException {

        if (queryFactory
                .selectFrom(member)
                .where(member.member_id.eq(member_id))
                .fetchOne() == null) {

            log.info("loadUserByUsername 실행 - 계정 정보 조회 실패");

            throw new UsernameNotFoundException(member_id + " - 없는 계정정보 입니다.");

        } else {
            Member auth_member = queryFactory
                    .selectFrom(member)
                    .where(member.member_id.eq(member_id))
                    .fetchOne();

            log.info("loadUserByUsername 실행 - 계정 정보 조회 성공");

            return createUserDetails(auth_member);
        }
    }

    private UserDetails createUserDetails(Member member) {

        log.info("createUserDetails 실행");

        return User.builder()
                .username(member.getMember_id())
                .password(member.getPassword())
                .roles(member.getRoles().toArray(new String[0]))
                .build();
    }
}

위의 Service에서 사용자 인증을 거친 Authentication 객체를 생성하기 위해 UserDetailsServiceImpl 에서 구현한 loadUserByUsername 메소드가 실행되어 검증을 한번 더 진행한다고 하였다.

  • 로그인하고자 하는 아이디를 입력받아서 조회했을 때 존재하지 않으면 UsernameNotFoundException 에러 처리
  • 존재한다면, 조회하여 Member 객체 생성 후 createUserDetails 메소드 실행
  • 결국에 최종적으로 Service단에 반환되어질 값은 Spring Security에서 활용할 유저의 정보이기 때문에 UserDetails를 반환받아야한다. 따라서, createUserDetails는 UserDetails 객체를 따로 만들어줄 메소드이다.
  • 생성된 exist_member 객체를 createUserDetails에 넘겨주어 User 클래스에 builder를 통해 대입하여 UserDetails객체를 생성해준다. User는 UserDetails를 상속받고 있기 때문에 User 로 UserDetails 객체 생성이 가능하다.

 

 

 

TokenDto

import lombok.Builder;
import lombok.Getter;
import java.util.Date;

@Getter
@Builder
public class TokenDto {
    private String grantType;
    private String accessToken;
    private String refreshToken;
    private Date accessTokenExpiresIn;
}

TokenProvider에 있는 generateToken을 통해 생성된 토큰에 대한 정보들은 Token 엔티티에 바로 적용되는 것이 아니라고 하였다.

앞서 말한 중간 저장 지점인 TokenDto를 만들어주어 중간 저장 지점으로 만들어준다.

  • grantType : "Bearer "
  • accessToken : 생성된 액세스 토큰
  • refreshToken : 생성된 리프레시 토큰
  • accessTokenExpiresIn : 액세스 토큰 만료 시간

 

 

StatusCode

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Getter
public enum StatusCode {

    // 정상 status
    OK(200, "정상 처리 완료"),

    // 잘못된 요청 status
    DUPLICATE_ACCOUNT(452, "이미 존재하는 계정입니다."),
    NOT_MATCH_PASSWORD(453, "비밀번호가 재확인 비밀번호와 일치하는지 확인해주십시오."),
    NOT_EXIST_ACCOUNT(454, "존재하지 않는 계정입니다."),
    INCORRECT_PASSWORD(455, "비밀번호를 다시 확인해주십시오.");


    private final int statusCode;
    private final String status;

}

Service 단에서 사용자 검증 로직을 두 가지 추가하였으니 StatusCode 도 새로 업데이트 해준다.

 

 

 

이제 포스트맨으로 확인해보자.

아이디와 비밀번호를 입력받아 정상적으로 토큰 값들이 뽑히는 것을 확인할 수 있다.

이제 이후에 구현할 기능들 중에서 로그인이 정상적으로 된 유저들만 사용이 가능한 기능들의 경우에 AccessToken을 헤더에 넣어서 요청하면 된다.

반대로 로그인하든 안하든 로그인이 딱히 필요없는 기능들의 경우에는 SecurityConfig 에서 antmatchers.permitAll()를  통해 권한을 해제해주도록 하자.

 

728x90
반응형
LIST

'프로젝트 > 라이프 챌린지' 카테고리의 다른 글

[회원관리] 회원탈퇴  (0) 2023.01.21
[회원관리] 로그아웃  (0) 2023.01.21
[회원관리] 회원가입  (0) 2023.01.17
[회원관리] JWT 세팅  (0) 2023.01.16
라이프 챌린지 프로젝트 시작 계기  (0) 2023.01.14