프로젝트에서 Spring Security + JWT + Redis 방식으로 인증/인가 로직을 구현하였고, 전체 과정을 기록으로 남겨두려 한다.
[요청 Dto(LoginRequestDto) 생성]
// 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 계층 구현]
// 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()
메서드를 통해 인증 과정이 이루어지며, 다음과 같은 순서로 진행된다.authenticate()
메서드를 통해 인증 과정을 수행한다.authenticate()
메서드 수행 중, UserDetailsService의 loadUserByUsername()
메서드를 통해 실제 DB에 저장된 사용자 정보와 일치하는지 확인하는 로직이 수행되며, 해당 메서드는 직접 구현해주어야 한다.authenticate()
메서드가 문제없이 마무리되면 authenticated 필드가 true로 설정된 Authentication 객체가 반환된다. (authentication
변수에 담긴다)authentication
) 내 저장되어 있는 사용자 아이디와 권한 정보를 JwtTokenProvider의 generateToken()
메서드로 전달하여 Access Token과 Refresh Token을 생성하고 반환한다.AuthenticationManager.authenticate()
메서드의 인증 과정 중 UserDetailsService.loadUserByUsername()
메서드에서 사용자 아이디를 기준으로 User Entity를 조회하는 과정이 포함되어 있으므로, 해당 과정에서는 orElseThrow()
와 같은 예외 처리 없이 get()
으로 바로 받아오도록 했다.UserDetailsService.loadUserByUsername()
에서 예외가 발생할 것이다!authenticate()
메서드 수행 과정 중 UserDetailsService의 loadUserByUsername()
메서드가 수행된다고 했었다. 그리고 해당 메서드는 직접 구현해주어야 한다.loadUserByUsername()
은 DB로부터 Username(사용자가 입력한 아이디)을 기준으로 조회하고, 조회된 사용자 정보를 UserDetails 객체에 담아 반환하는 역할을 수행한다.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;
}
}
getAuthorities()
, getPassword()
, getUsername()
등을 필요에 맞게 구현하면 된다.getAuthorities()
의 경우, GrantedAuthority를 상속받는 객체들이 담긴 컬렉션으로 반환되어야 하는데, 이 때 SimpleGrantedAuthority 클래스를 활용할 수 있으며, 생성자 파라미터로 전달할 때는 ROLE_
을 prefix로 붙인 문자열을 전달해주어야 한다.// 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);
}
}
loadUserByUsername()
메서드만 구현하면 된다.UsernameNotFoundException
이 발생한다. (해당 예외는 AuthenticationException
을 상속받고 있다.)public enum ExceptionMessage {
AUTHENTICATION_FAILED("인증 실패"),
AUTHORIZATION_FAILED("접근 권한 없음");
private final String message;
ExceptionMessage(String message) {
this.message = message;
}
public String getMessage() {
return this.message;
}
}
[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)
)
);
}
...
}
[결과 확인]
인증/인가 관련 예외처리 과정
에서 다룬다.[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();
}
}
...
}
[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();
}
...
}
validateSignUpUserInfo()
)userRepository.insert()
메서드가 활용된다.save()
메서드를 활용하면 될 것이다!setFirstAuthentication()
메서드를 그대로 활용하여 인증 절차를 거친 후, 토큰 정보를 받아온다.위 코드에서는 회원가입 과정에서 불필요한 인증 절차를 거친다.
JwtTokenProvide의 generateToken()
메서드는 사용자의 아이디와 권한 정보를 토대로 JWT를 생성하므로, 인증 절차를 거치지 않고 토큰 생성만 하도록 구현해도 될 것이다.
(위 코드에서는 그냥 인증 절차 하도록 했다..! 귀찮았..)
[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)
)
);
}
...
}