[백엔드 야생 도전일기] 스프링으로 회원가입/로그인기능 만들어보기 (4)
드디어 본격적인 로그인 기능을 만들 차례!!
우선 전에 만들어둔 회원가입 기능을 다시 살펴봤는데, 뭔가 이상했다. 생 비밀번호를 그대로 DB에 저장하고 있었다..! 내가 아무리 백엔드 지식이 전무하다지만 비밀번호를 그대로 저장하면 안 된다는 것 정도는 알고 있었다. 역시 찾아보니까 암호화하여 저장하는 방법이 있었다.
우선 다음과 같이 PasswordEncoder를 빈으로 등록해준다. BCryptPasswordEncoder는 PasswordEncoder라는 인터페이스를 구현한 놈이다.
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
그런다음 회원가입 코드를 다음과 같이 수정했다.
@PostMapping("/signup")
public String signup(SignupDTO signupDTO) {
String rawPassword = signupDTO.getPassword();
String encodedPassword = passwordEncoder.encode(rawPassword);
Member newMember = new Member();
newMember.setEmail(signupDTO.getEmail());
newMember.setPassword(encodedPassword);
newMember.setNickname(signupDTO.getNickname());
newMember.setRole(Role.USER);
memberService.join(newMember);
return "success";
}
passwordEncoder의 encode메서드를 통해 암호화해주고 이를 저장하는 모습. 아 그리고 MemberForm클래스의 이름을 SignupDTO로 바꿔줬다.
그런 다음 우선 로그인 로직을 만들어줬다. SignupDTO와 비슷하게 유저가 로그인할 때의 이메일, 비밀번호를 담아 전달하는 LoginDTO를 만들어줬다.
@Data
public class LoginDTO {
private String email;
private String password;
}
@Data란 어노테이션도 롬복에서 주는 놈으로, @Getter, @Setter, @ToString, @RequiredArgsConsructor 등을 한꺼번에 합쳐둔 종합 어노테이션이라고 한다.
그리고 이를 활용해 AccountController에서 로그인 로직을 다음과 같이 짜줬다.
@PostMapping("/login")
public String login(@RequestBody LoginDTO loginInfo) {
String loginEmail = loginInfo.getEmail();
String loginRawPassword = loginInfo.getPassword();
if (!memberService.login(loginEmail, loginRawPassword)) {
throw new IllegalStateException("로그인에 실패했습니다.");
}
return tokenProvider.createToken(loginEmail, jwtSecretKey, EXPIRED_MS);
}
우선 사용자가 입력한 이메일과 비밀번호를 LoginDTO로 받는다. @RequestBody는 클라이언트가 보내준 HTTP의 BODY에 담긴 데이터를 LoginDTO에 매핑해주는 역할이라고 생각하면 된다. 스프링이 자동으로 JSON데이터를 파라미터로 적힌 자바객체로 역직렬화해주는 것. (참고로 클라 쪽에서 요청데이터를 body에 담고 content-type을 application/json으로 해줘야 한다고 함) 암튼 그 다음 DTO로부터 이메일과 비밀번호를 추출한 후, memberService의 로그인메서드를 호출한다. 이 메서드는 사용자가 입력한 이메일과 비밀번호를 갖는 사람(Member)가 있으면 true를 리턴하고, 아니면 false를 리턴한다! 그래서 memberService의 로그인 메서드가 true를 리턴했을 때 토큰을 발급하도록 만들어줬다.
MemberService의 로그인 메서드는 다음과 같다.
public boolean login(String email, String rawPassword) {
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("이메일을 다시 확인해주세요"));
if (isMatchedPassword(member, rawPassword)) {
return LOGIN_SUCCESS;
}
return LOGIN_FAIL;
}
private boolean isMatchedPassword(Member member, String rawPassword) {
// 스트링의 equals메서드로 하면 안됨. encode결과값이 그때그때 달라서리..
return passwordEncoder.matches(rawPassword, member.getPassword());
}
처음에는 isMatchedPassword메서드에서 단순히 member.getPassword()와 파라미터로 받은 rawPassword를 encode한 두 문자열이 같은지를 equals메서드를 통해 비교했었다. 근데 결과는 항상 다르다고 나왔다. 뭔가 하고 보니, PasswordEncoder로 같은 문자열을 encode함에도 불구하도 할 때마다 그 결과값이 계속 달라졌다. 찾아보니까 Bcrypt라는 방식이 해쉬를 멕일 때마다 랜덤의 솔트(salt, 일종의 랜덤데이터를 말한다고 함)를 더해 매번 결과값이 달라지게 해서 보안성을 향상시킨 방법이라고 한다. 때문에 두 비밀번호가 같은지를 비교하려면 equals가 아니라 PasswordEncoder가 제공하는 matches기능을 활용해야 했다.
아 그리고! Bcrypt방식으로 해쉬를 멕인 결과물의 길이는 항상 60이라고 한다! 즉 mysql에서 비밀번호의 길이를 60으로 지정해줘야 함. 안 그러면 data too long이란 오류가 난다.
그러고는 로그인이 잘 동작하는지 확인하기 위해 테스트 코드를 짜줬다.
@Nested
class LoginTest {
@DisplayName("로그인 테스트")
@Test
void login() {
String email = "tt@naver.com";
String rawPassword = "123123";
Member member = new Member();
member.setEmail(email);
member.setPassword(passwordEncoder.encode(rawPassword));
member.setNickname("testestrqe");
member.setRole(Role.USER);
memberService.join(member);
boolean result = memberService.login(email, rawPassword);
assertThat(result).isTrue();
}
@DisplayName("비밀번호를 틀려서 로그인하면 실패한다")
@Test
void loginByNotExistingEmail() {
String email = "tt@naver.com";
String rawPassword = "123123";
Member member = new Member();
member.setEmail(email);
member.setPassword(passwordEncoder.encode(rawPassword));
member.setNickname("testestrqe");
member.setRole(Role.USER);
memberService.join(member);
boolean result = memberService.login(email, "@!31232132");
assertThat(result).isFalse();
}
}
결과는 기분좋게 통과!
그리고 이제 시큐리티랑 짝짝꿍할 차례. 일단 jwt관련해 사용자의 인증 정보를 가져오는 기능을 만들지 않았었으니 그 부분부터 따라만들기로 했다.
public Authentication getAuthentication(String token, String jwtSecretKey) {
String email = getUserEmailFromToken(token, jwtSecretKey);
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
UserDetails : 인터페이스고, 사용자에 대한 핵심 정보를 담고 제공하는 객체 역할! 기본적으로 갖는 메서드들은 다음과 같다.
메서드명 | 리턴 타입 | 설명 | 기본값 |
getAuthorities() | Collection<? extends GrantedAuthority> | 계정의 권한 목록 리턴 | |
getPassword() | String | 계정의 비번 리턴 | |
getUsername() | String | 계정의 고유값 리턴(DB pk 또는 중복이 없는 필드 등) | |
isAccountNonExpired() | boolean | 계정의 만료여부 리턴 | true |
isAccountNonLocked() | boolean | 계정의 잠김여부 리턴 | true |
isCredentialsNonExpired() | boolean | 비밀번호 만료여부 리턴 | true |
isEnabled() | boolean | 계정의 활성화여부 리턴 | true |
UserDetailsService : 사용자별 데이터(UserDetails)를 로드하는 놈이고 역시나 인터페이스. UserDetails타입을 리턴하는 loadUserByUsername이란 기본 메서드를 가지고 있다.
음..근데 예네들 왜 쓰는걸까? 왜 userDetails가 왜 있는걸까? 에 대해 알아봤다.
일단 아주 러프하게 보면, 시큐리티에서는 사용자 요청에 대한 검증(이 놈이 제대로 된 놈인지)을 해주는데 이 때 인증하고자 하는 회원의 정보를 불러올 수 있어야 한다! 이를 위해서 DB에 접근할 수 있어야 하고, 이를 UesrDetailsService가 하도록 한 것. 또한 회원 정보를 DB에 저장된 형태 그대로 가져오는게 아니라 시큐리티가 사용할 수 있는 형태(UserDetails)로 가져오게 한 거다. 인증에 성공하면, 요 정보를 SecurityContextHolder라는 곳(시큐리티 내부의 저장소)에 저장한다. 고 한다. 이때 SecurityContextHolder가 저장할 수 있는 객체는 Authentication타입이고, 이 객체는 User정보를 갖는데 이 정보가 UserDetails타입으로 정의돼있다! 그래서 UserDetails를 사용하는 것임. 참고로 나중에는 이렇게 저장된 Authentication을 통해서 인가해준다고 함.
그래서 위 코드는 결국..
- 토큰에서 이메일 빼옴
- 그걸로 DB뒤져서 해당하는 유저정보 가져옴, 이 정보는 UserDetails타입
- 그걸 Authentication에 담아서 리턴. (UsernamePasswordAuthenticationToken이 Authentication의 구현체임)
요로코롬 동작하는 것.
암튼 이를 위해서 UserDetails인터페이스를 구현한 놈과 UserDetailsService를 구현한 놈을 만들어줘야 했다. UserDetails의 경우 기존에 가진 Member클래스가 얘를 구현하게 하는 방식이 있고 Member와는 별도로 따로 CustomUserDetails를 만드는 방법이 있었는데, 난 전자를 선택했다.
@Getter
@Setter
@Entity
public class Member implements UserDetails {
@Id
@Column(name = "email")
private String email;
@Column(name = "password")
private String password;
@Column(name = "nickname")
private String nickname;
@Enumerated(EnumType.STRING)
private Role role;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(getRole().name()));
return authorities;
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
CustomUserDetailsService는 다음과 같이 만들어줬다.
@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다"));
}
}
그 다음엔 JwtAuthenticationFilter를 만들어줬다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
@Value("${jwt.secret}")
private String jwtSecretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = tokenProvider.getTokenFromHeader(request);
if (token != null && tokenProvider.validateToken(token, jwtSecretKey)) {
Authentication authentication = tokenProvider.getAuthentication(token, jwtSecretKey);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
클라이언트가 보낸 request의 header에서 토큰 빼내고, 그 놈이 문제가 없다면(?) Authentication을 만들어서 저장하는 모습이다. 그러곤 다음과 같이 SecurityConfig를 작성해줬다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity // 기본적인 웹 보안을 활성화하는 어노테이션
public class SecurityConfig {
private final JwtTokenProvider tokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf().disable() // CSRF 공격에 대한 방어를 해제
.cors().and() // cors 허용
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 서버를 stateless하게 유지 즉 세션 X
.and()
.formLogin().disable() // 시큐리티가 기본 제공하는 로그인 화면 없앰. JWT는 로그인과정을 수동으로 클래스로 만들어야 하니까
.httpBasic().disable() // 토큰 방식을 이용할 것이므로 보안에 취약한 HttpBasic은 꺼두기
.authorizeRequests() // HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다
.requestMatchers("/account/signup", "/account/login", "/").permitAll() // 요 세 놈에 대한 요청은 인증없이 접근 허용
.anyRequest().authenticated() // 나머지에 대해선 인증을 받아야 한다.
.and()
.addFilterBefore(new JwtAuthenticationFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class)
.build();
}
}
원래는 다음과 같이 만드는 방식이 있었는데,
@Configuration
@EnableWebSecurity // Spring Security 설정을 시작
public class SecurityConfig extends WebSecurityConfigurerAdapter {
이 방법은 막혔다고 한다.
이제 만들긴 해뒀고, 테스트해볼 차례!! 우선 회원가입했을 때 비밀번호가 암호화된 형식으로 잘 저장되는지 볼거고, 로그인을 할 때 토큰이 발급되는지 볼거다.
결과는..
ㅁ...무작정 따라하느라 이론은 좀 부족하지만,, 아무튼 성공! 2일을 이거 하느라 이것저것 삽질하느라 좀 졸리다..자러 가야겠음 ㅠ