[Spring Security, JWT, Redis] 로그인, 회원가입 구현 (UserDetails, UserDetailsService)

3Beom's 개발 블로그·2023년 8월 13일
0

프로젝트에서 Spring Security + JWT + Redis 방식으로 인증/인가 로직을 구현하였고, 전체 과정을 기록으로 남겨두려 한다.


로그인(+회원가입) 인증 과정

  • 본 과정은 로그인, 회원가입 후 사용자에게 Access Token과 Refresh Token을 생성하여 전달하는 과정이다.
    • 회원가입의 경우 회원 정보를 DB에 저장만 하고 사용자가 로그인 해서 Token들을 받도록 할 수도 있지만, 회원가입을 하면 자동으로 로그인 상태를 유지하도록 설정하면 사용자 입장에서 편할 것 같아 이렇게 구현하였다.
  • 본 과정에서 구현되는 내용들은 로그인을 기준으로 구현되었다.
    • Http Request를 통해 전달된 아이디, 비밀번호가 DB에 저장되어 있는 정보와 동일한지 확인하는 과정이다.

로그인 api request, response dto 및 service 계층 구현

[요청 Dto(LoginRequestDto) 생성]

  • 로그인 api의 요청 파라미터를 받기 위한 Dto를 생성한다.
  • 본 Dto에서는 사용자가 로그인을 하기 위해 입력한 아이디와 패스워드가 담기게 된다.
// UserRequestDto

public class UserRequestDto {
...

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class LoginRequestDto {

        private String id;
        private String password;
    }
...
}

[응답 Dto(AuthenticatedResponseDto) 생성]

  • 로그인, 회원가입 등 인증에 성공할 경우의 응답이다.
  • 응답 내용으로 토큰 정보와 사용자 정보를 전달하도록 구현하였다.
// UserResponseDto

public class UserResponseDto {
...
    @Getter
    public static class AuthenticatedResponseDto {

        private final TokenInfo tokens;
        private final UserInfoResponseDto userInfo;

        @Builder
        public AuthenticatedResponseDto(TokenInfo tokenInfo, User user) {
            tokens = tokenInfo;
            userInfo = UserInfoResponseDto.builder().user(user).build();
        }
    }

    @Getter
    public static class UserInfoResponseDto {

        private final String email;
        private final Integer emailAlert;
        private final Long countryId;
        private final String countryCode;
        private final String nickname;
        private final LocalDate birth;
        private final String profileImg;
        private final Integer point;
        private final RankName rankName;
        private final String rankImg;
        private final UserRole role;

        @Builder
        public UserInfoResponseDto(User user) {
            this.email = user.getEmail();
            this.emailAlert = user.getEmailAlert();
            this.countryId = user.getCountry().getCountryId();
            this.countryCode = user.getCountry().getCode();
            this.nickname = user.getNickname();
            this.birth = user.getBirth();
            this.profileImg = user.getProfileImg();
            this.point = user.getPoint();
            this.rankName = user.getRank().getName();
            this.rankImg = user.getRank().getRankImg();
            this.role = user.getRole();
        }
    }
...
}

[Service 계층 구현]

  • Service 계층에서 로그인 api의 메서드를 구현한다. (UserService.login())
  • 해당 메서드는 LoginReqeustDto를 인자로 전달받고, AuthenticatedResponseDto를 반환한다.
// UserServiceImpl

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserServiceImpl implements UserService {
...
    private final UserRepository userRepository;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final JwtTokenProvider jwtTokenProvider;
    private final PasswordEncoder passwordEncoder;

...

    @Override
    @Transactional
    public AuthenticatedResponseDto login(LoginRequestDto loginRequestDto) {
        TokenInfo tokenInfo = setFirstAuthentication(loginRequestDto.getId(),
            loginRequestDto.getPassword());
        log.debug("login token : {}", tokenInfo);

        User user = userRepository.findById(loginRequestDto.getId()).get();
        user.setRefreshToken(tokenInfo.getRefreshToken());

        return AuthenticatedResponseDto.builder()
            .tokenInfo(tokenInfo)
            .user(user)
            .build();
    }

    private TokenInfo setFirstAuthentication(String id, String password) {
        // 1. id, password 기반 Authentication 객체 생성, 해당 객체는 인증 여부를 확인하는 authenticated 값이 false.
        UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(id, password);

        // 2. 검증 진행 - CustomUserDetailsService.loadUserByUsername 메서드가 실행됨
        Authentication authentication = authenticationManagerBuilder.getObject()
            .authenticate(authenticationToken);

        return jwtTokenProvider.generateToken(authentication.getAuthorities(),
            authentication.getName());
    }
  • setFirstAuthentication() 메서드를 통해 인증 과정이 이루어지며, 다음과 같은 순서로 진행된다.
    1. 사용자가 입력한 아이디와 패스워드를 인자로 전달하여 UsernamePasswordAuthenticationToken 객체를 생성한다.
      • 아이디, 패스워드 기반의 Authentication 객체를 생성한 것이다.
      • 해당 객체는 authenticated 필드가 false로 설정된, 인증되지 않은 Authentication 객체이다.
    2. AuthenticationManager의 authenticate() 메서드를 통해 인증 과정을 수행한다.
      • 앞서 다루었듯, AuthenticationManager 내 AuthenticationProvider들을 통해 인증 과정이 수행된다.
      • authenticate() 메서드 수행 중, UserDetailsService의 loadUserByUsername() 메서드를 통해 실제 DB에 저장된 사용자 정보와 일치하는지 확인하는 로직이 수행되며, 해당 메서드는 직접 구현해주어야 한다.
    3. AuthenticationManager의 authenticate() 메서드가 문제없이 마무리되면 authenticated 필드가 true로 설정된 Authentication 객체가 반환된다. (authentication 변수에 담긴다)
    4. 인증된 Authentication 객체(authentication) 내 저장되어 있는 사용자 아이디와 권한 정보를 JwtTokenProvider의 generateToken() 메서드로 전달하여 Access Token과 Refresh Token을 생성하고 반환한다.
  • 인증 과정이 모두 수행된 후, 아이디를 기준으로 DB에서 User Entity를 조회하여 Refresh Token을 수정하고 AuthenticatedResponseDto를 반환한다.
    • AuthenticationManager.authenticate() 메서드의 인증 과정 중 UserDetailsService.loadUserByUsername() 메서드에서 사용자 아이디를 기준으로 User Entity를 조회하는 과정이 포함되어 있으므로, 해당 과정에서는 orElseThrow() 와 같은 예외 처리 없이 get() 으로 바로 받아오도록 했다.
      • DB에 유저 정보가 없으면 UserDetailsService.loadUserByUsername() 에서 예외가 발생할 것이다!

UserDetails 구현

  • AuthenticationManager의 authenticate() 메서드 수행 과정 중 UserDetailsService의 loadUserByUsername() 메서드가 수행된다고 했었다. 그리고 해당 메서드는 직접 구현해주어야 한다.
  • loadUserByUsername() 은 DB로부터 Username(사용자가 입력한 아이디)을 기준으로 조회하고, 조회된 사용자 정보를 UserDetails 객체에 담아 반환하는 역할을 수행한다.
  • 이에 따라 구현해야 하는 것은 CustomUserDetailsService와 CustomUserDetails 이다.
    • CustomUserDetailsService.loadUserByUsername()에서 CustomUserDetails 객체를 반환하는 것!
// CustomUserDetails

@Getter
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singletonList(new SimpleGrantedAuthority(this.user.getRole().name()));
    }

    @Override
    public String getPassword() {
        return this.user.getPassword();
    }

    @Override
    public String getUsername() {
        return this.user.getId();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • UserDetails 인터페이스를 implements 하여 CustomUserDetails를 구현할 수 있다.
  • User 엔티티 객체를 멤버 변수로 갖도록 하고, getAuthorities(), getPassword(), getUsername() 등을 필요에 맞게 구현하면 된다.
  • getAuthorities() 의 경우, GrantedAuthority를 상속받는 객체들이 담긴 컬렉션으로 반환되어야 하는데, 이 때 SimpleGrantedAuthority 클래스를 활용할 수 있으며, 생성자 파라미터로 전달할 때는 ROLE_ 을 prefix로 붙인 문자열을 전달해주어야 한다.
    • ROLE_USER
    • ROLE_ADMIN
    • ROLE_ANONYMOUS

UserDetailsService 구현

// CustomUserDetailsService

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

    private final UserRepository userRepository;
    private final Logger logger = LoggerFactory.getLogger(CustomUserDetailsService.class);

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // TODO : UsernameNotFountException 예외처리 확인
        User user = userRepository.findById(username).orElseThrow(
            () -> new UsernameNotFoundException(
                ExceptionMessage.AUTHENTICATION_FAILED.getMessage()));
        logger.debug("loadUserByUsername user : {}", user);

        return new CustomUserDetails(user);
    }

}
  • UserDetailsService 인터페이스를 implements하여 CustomUserDetailsService를 구현할 수 있다.
  • loadUserByUsername() 메서드만 구현하면 된다.
  • UserRepository를 주입받아 DB로부터 사용자 아이디를 기준으로 조회한다.
    • 만약 조회되는 사용자가 없을 경우, 에러 메세지와 함께 UsernameNotFoundException 이 발생한다. (해당 예외는 AuthenticationException 을 상속받고 있다.)
    • 예외 메세지는 다음과 같이 따로 Enum으로 만들어 관리하였다.
    • ExceptionMessage
      public enum ExceptionMessage {
          AUTHENTICATION_FAILED("인증 실패"),
          AUTHORIZATION_FAILED("접근 권한 없음");
      
          private final String message;
      
          ExceptionMessage(String message) {
              this.message = message;
          }
      
          public String getMessage() {
              return this.message;
          }
      }
  • 조회된 User Entity를 CustomUserDetails 객체에 담아 반환한다.
  • 비밀번호 일치 과정은 SecurityConfig에서 등록했던 PasswordEncoder Bean 객체를 통해 Spring Security 내부적으로 이루어진다.
    • 따라서 조회된 User Entity에는 password가 해당 PasswordEncoder를 통해 암호화된 상태로 저장되어 있어야 한다.

로그인 api controller 계층 구현 및 결과 확인

[controller 구현]

  • 이제 로그인 과정을 수행하는 Service 메서드는 구현되었다.
  • 해당 메서드를 활용하는 Controller 메서드를 구현해야 한다.
// UserApiController

@Slf4j
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserApiController {

    private final UserService userService;

...

    @PostMapping("/login")
    public ResponseEntity<ResponseDto<?>> login(
        @RequestBody LoginRequestDto loginRequestDto) {
        return ResponseEntity.status(HttpStatus.OK).body(
            ResponseDto.create(
                LOGIN_SUCCESS.getMessage(),
                userService.login(loginRequestDto)
            )
        );
    }

...
}
  • LoginRequestDto로 사용자가 입력한 아이디와 패스워드를 전달받고, UserService의 login() 메서드를 통해 인증 절차를 거친 후, AuthenticatedResponseDto를 반환한다.

[결과 확인]

  • 로그인에 성공할 경우, 위 사진과 같이 토큰 정보와 사용자 정보가 반환되는 것을 확인할 수 있다.
  • 만약 로그인에 실패할 경우, AuthenticationException이 발생하며, 해당 예외는 AuthenticationEntryPoint에서 관리할 수 있다.
  • 해당 예외처리 과정은 다음 인증/인가 관련 예외처리 과정 에서 다룬다.
    👉 [Spring Security, JWT, Redis] 인증/인가 관련 예외 처리

회원가입 추가

  • 회원가입의 경우, 사용자 정보를 DB에 저장하고, 로그인 과정에서 거쳤던 인증 절차를 그대로 적용하여 토큰 정보를 반환해주기만 하면 된다.

[Request, Response Dto 구현]

// UserRequestDto

public class UserRequestDto {
...
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class SignUpRequestDto {

        private String id;
        private String password;
        private String email;
        private Long countryId;
        private Integer emailAlert;
        private String nickname;
        private String birth;
        private String profileImg;
        private UserRole role;

        public User toUser(String encodedPassword, Country country) {
            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

            return User.builder()
                .id(this.id)
                .password(encodedPassword)
                .email(this.email)
                .country(country)
                .emailAlert(this.emailAlert)
                .nickname(this.nickname)
                .birth(LocalDate.parse(this.birth, formatter))
                .profileImg(this.profileImg)
                .role(this.role)
                .build();
        }
    }
...
}
  • ResponseDto는 앞서 로그인에서 활용했던 AuthenticatedResponseDto를 그대로 활용한다.

[service 구현]

// UserServiceImpl

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;
    private final RankRepository rankRepository;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final JwtTokenProvider jwtTokenProvider;
    private final PasswordEncoder passwordEncoder;

...

    @Override
    @Transactional
    public AuthenticatedResponseDto signUp(SignUpRequestDto signUpRequestDto) {
        validateSignUpUserInfo(signUpRequestDto);

        User user = signUpRequestDto.toUser(
            passwordEncoder.encode(signUpRequestDto.getPassword()),
            userRepository.findOneCountry(signUpRequestDto.getCountryId()));

        Rank bronzeRank = rankRepository.getRankByName(RankName.BRONZE)
            .orElseThrow(() -> new RankNotFoundException(RANK_NOT_FOUND.getMessage()));

        user.setRank(bronzeRank);
        user.setPointZero();

        userRepository.insert(user);

        log.debug("user : {}", user);

        TokenInfo tokenInfo = setFirstAuthentication(signUpRequestDto.getId(),
            signUpRequestDto.getPassword());
        log.debug("token : {}", tokenInfo);

        user.setRefreshToken(tokenInfo.getRefreshToken());

        return AuthenticatedResponseDto.builder()
            .tokenInfo(tokenInfo)
            .user(user)
            .build();
    }

...
}
  • 필요한 validation을 거친다. (validateSignUpUserInfo())
  • JpaRepository 인터페이스를 활용하지 않고 UserRepository 내용을 직접 구현하여 userRepository.insert() 메서드가 활용된다.
    • JpaRepository 인터페이스를 활용할 경우, save() 메서드를 활용하면 될 것이다!
  • 로그인 과정에서 활용되었던 setFirstAuthentication() 메서드를 그대로 활용하여 인증 절차를 거친 후, 토큰 정보를 받아온다.
    • 위 코드에서는 회원가입 과정에서 불필요한 인증 절차를 거친다.

    • JwtTokenProvide의 generateToken() 메서드는 사용자의 아이디와 권한 정보를 토대로 JWT를 생성하므로, 인증 절차를 거치지 않고 토큰 생성만 하도록 구현해도 될 것이다.

      (위 코드에서는 그냥 인증 절차 하도록 했다..! 귀찮았..)

[controller 구현]

  • 회원가입 api controller 계층을 구현한다.
// UserApiController

@Slf4j
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserApiController {

    private final UserService userService;

...

    @PostMapping("")
    public ResponseEntity<ResponseDto<?>> signUp(@RequestBody SignUpRequestDto signUpRequestDto) {
        return ResponseEntity.status(HttpStatus.CREATED).body(
            ResponseDto.create(
                SIGN_UP_SUCCESS.getMessage(),
                userService.signUp(signUpRequestDto)
            )
        );
    }
...
}
profile
경험과 기록으로 성장하기

0개의 댓글