안녕하세요.
오늘은 1편 Spring Security로 회원가입을 해보자 (1) - 회원 엔티티 설계와
2편 Spring Security로 회원가입을 해보자 (2) - SecurityConfig 및 회원가입에 이은
3편 로그인 입니다.
이번 포스팅은 로그인 설정이 주를 이룰 것 같네요.
이번 편은 제 코드에 대한 확신이 있는 것은 아닙니다.
저도 학습 중이고, 하나씩 적용해보고 있어서...
(전에 올렸던 CRUD와 1:N 연관관계, 회원가입은 나름의 확신?이 있었는데 말이죠.)
아무래도 Spring Security를 사용해 DB에 회원 정보를 가져오고,
그것을 가지고 작업을 하는 게
단순 select로 되는 것이 아니다보니 그렇습니다.
일단 모든 테스트코드는 통과됩니다.
하지만 틀린 부분이 있을 수 있으니, 참고만 해주세요.
📌 목표
- View Page는 만들지 않고, 테스트코드만으로 로직을 검증한다.
먼저 DB에서 username을 반환할 겁니다.
UserDetailService를 상속받아 UserDetials를 반환하는 클래스를 생성합니다.
MemberService에 추가할까 했지만,
로그인 기능만을 위한 LoginService를 따로 생성했습니다.
✔️ LoginService
UserDetailService를 상속받으면, loadUserByname()이 오버라이딩 해야합니다.
기본 틀 입니다.
@RequiredArgsConstructor
@Service
public class LoginService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return null;
}
}
- UserDetailsService
- UserDetailService 인터페이스는 데이터베이스에서 회원 정보를 가져오는 역할을 담당합니다.
- loadUserByname() 메소드가 존재하며, 회원 정보를 조회하여 사용자의 정보와 권한을 갖는 UserDetails를 반환합니다.
- 스프링 시큐리티에서 UserDetailsService를 구현하고 있는 클래스를 통해, 로그인 기능을 구현한다고 보면 됩니다.
- UserDetails
- 스프링 시큐리티에서 회원의 정보를 담기 위해 사용하는 인터페이스 입니다.
- 이 인터페이스를 직접 구현하거나, 스프링 시큐리티에서 제공하는 User 클래스를 사용합니다.
- User 클래스는 UserDetails 인터페이스를 구현하고 있는 클래스입니다.
다음은 완성 코드입니다.
@RequiredArgsConstructor
@Service
public class LoginService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email).orElseThrow(
() -> new UsernameNotFoundException("해당 회원이 존재하지 않습니다. eamil = " + email)
);
return User.builder()
.username(member.getEmail())
.password(member.getPassword())
.roles(member.getRole().toString())
.build();
}
}
- UserDetail을 구현하고 있는 User 객체를 반환해줍니다.
- User 객체를 생성하기 위해서 생성자로 회원의 이메일, 비밀번호, role를 파라미터로 넘겨 줍니다.
여기서 제가 회원 엔티티를 User로 하지 않고, Member로 설정한 이유가 나옵니다.
UserDetail의 User와 헷갈리기 때문이죠.
예전 시큐리티 학습 때 User로 했더니 헷갈린 적이 있어서;;;
맨 위의 import를 보고, 어떤 User가 import 됐나 확인했던 경험이 있습니다.
따라서 이번은 Member로 했습니다.
(MySQL도 User로 하면 에러남)
다음은 SecurityConfig를 수정할 겁니다.
✔️ SecurityConfig
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable() // Spring Security는 csrf 토큰 없이 요청하면 해당 요청을 막음
.authorizeRequests() // HttpServletRequest에 따라 접근을 제한한다.
.antMatchers("/login", "/signUp", "/").permitAll() // antMatchers() 메소드로 특정 경로를 지정하며, permitAll(),hasRole() 메소드로 권한에 따른 접근 설정을 한다.
.anyRequest().authenticated() // 그 외의 경로는 인증된 사용자만이 접근이 가능하다.
.and()
.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/")
.usernameParameter("email") // 아이디 파라미터명 설정, default: username
.and()
.logout()
.logoutSuccessUrl("/")
.invalidateHttpSession(true);
return http.build();
}
// 비밀번호 암호화 저장
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
- andMatchers : 경로 설정과 권한 설정이 가능합니다.
- permiatAll() : 누구나 접근 가능
- hasRole() : 특정 권한이 있는 사람만 접근 가능
- authenticated() : 권한이 있으면 무조건 접근 가능
- loginPage() : 로그인 페이지 url 설정
- defaultSuccessful() : 로그인 성공 후 리다이렉트 할 주소
- logoutSuccessUrl() : 로그아웃 성공 후 리다이렉트 할 주소
- invalidateHttpSession() : 로그아웃 이후 세션 전체 삭제 여부
이부분이 헷갈리고 어렵네요.
일단은 이렇게 설정했고, 계속해서 수정해나가고 있습니다.
다음은 회원정보 수정 필드를 가진 MemberUpdateRequestDto를 생성하겠습니다.
✔️ MemberUpdateRequestDto
@Getter
@NoArgsConstructor
public class MemberUpdateRequestDto {
private String password;
private String nickname;
@Builder
public MemberUpdateRequestDto(String password, String nickname) {
this.password = password;
this.nickname = nickname;
}
}
- 비밀번호와 닉네임을 수정할 수 있습니다.
다음은 회원정보 수정과 삭제 로직을 MemberService에 추가하겠습니다.
✔️ MemberService
@RequiredArgsConstructor
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
...
/**
* 회원 정보 수정
* */
@Transactional
public Long update(Long memberId, MemberUpdateRequestDto memberUpdateRequestDto) {
Member member = memberRepository.findById(memberId).orElseThrow(
() -> new IllegalArgumentException("해당 회원이 없습니다. memberId = " + memberId)
);
member.updatePassword(passwordEncoder, memberUpdateRequestDto.getPassword());
member.updateNickname(memberUpdateRequestDto.getNickname());
return memberId;
}
/**
* 회원 정보 불러오기
* */
@Transactional(readOnly = true)
public MemberResponseDto findById(Long memberId) {
Member entity = memberRepository.findById(memberId).orElseThrow(
() -> new IllegalArgumentException("해당 회원이 없습니다. memberId = " + memberId)
);
return new MemberResponseDto(entity);
}
/**
* 회원 정보 삭제
* */
@Transactional
public void delete (Long memberId) {
Member member = memberRepository.findById(memberId).orElseThrow(
() -> new IllegalArgumentException("해당 회원이 없습니다. memberId = " + memberId)
);
memberRepository.delete(member);
}
}
- 회원정보 불러오기는 마이페이지 기능을 위해 만들었습니다.
- 하지만 지금은 테스트하지 않았습니다.
회원가입 로직은 전에 했던 CRUD와 비슷합니다.
1. 파라미터로 받은 memberId로 회원 정보를 조회하고,
2. 조회된 회원정보를 update 하거나
3. delete 합니다.
다음은 MemberController를 작성하겠습니다.
✔️ MemberController
@RequiredArgsConstructor
@Controller
public class MemberController {
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
public final MemberRepository memberRepository;
@GetMapping("/")
public @ResponseBody String index() {
return "index 페이지 입니다.";
}
@GetMapping("/login")
public String loginForm() {
return "member/loginForm";
}
@GetMapping("/signUp")
public String signUpForm(Model model) {
model.addAttribute("MemberSaveRequestDto", new MemberSaveRequestDto());
return "member/signUpForm";
}
@PostMapping("/signUp")
public @ResponseBody Long signUp(@Valid MemberSaveRequestDto memberSaveRequestDto, BindingResult bindingResult) {
if(bindingResult.hasErrors()) {
throw new IllegalArgumentException("Error");
}
return memberService.saveMember(memberSaveRequestDto);
}
@PutMapping("/member/{memberId}")
public @ResponseBody Long update(@PathVariable("memberId") long memberId, @RequestBody MemberUpdateRequestDto memberUpdateRequestDto) {
return memberService.update(memberId, memberUpdateRequestDto);
}
@DeleteMapping("/member/{memberId}")
public @ResponseBody Long delete(@PathVariable("memberId") long memberId) {
memberService.delete(memberId);
return memberId;
}
@GetMapping("/member/{memberId}")
public MemberResponseDto findById(@PathVariable("memberId") long memberId) {
return memberService.findById(memberId);
}
}
- @ResponseBody : 자바 객체를 json 기반의 HTTP Body로 변환 (출처)
- 서버 -> 클라이언트 응답
- View페이지가 아닌 반환값 그대로 return하고 싶어서 설정했습니다.
- @RestController로 하면 되지 않나 하실 수 있지만, View Page 테스트를 해본다고 Controller로 설정해 view를 반환한 게 있어서...
- @RequestBody : json 기반의 HTTP Body를 자바 객체로 변환
- 클라이언트 -> 서버 요청
- 회원가입 - POST : /signUp
- 로그인 - POST : /login
- 회원정보 수정 - PUT : /member/{memberId}
- 회원정보 삭제 - DELETE : /member/{memberId}
- 회원정보 조회 - GET : /member/{memberId}
다음은 LoginService를 테스트해보겠습니다.
✔️ LoginServiceTest
먼저 큰 틀 입니다.
@AutoConfigureMockMvc // MockMvc 테스트를 위해 선언
@Transactional
@SpringBootTest
class LoginServiceTest {
@Autowired
private MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Autowired
private MockMvc mockMvc;
@Autowired
PasswordEncoder passwordEncoder;
@Autowired
EntityManager em;
@AfterEach
private void after(){
em.clear();
}
private void clear(){
em.flush();
em.clear();
}
@BeforeEach
private void init(){
memberRepository.save(Member.builder()
.password(passwordEncoder.encode("123456789"))
.nickname("ummchicken")
.email("aaa@naver.com")
.role(Role.USER)
.build());
clear();
}
// 테스트코드 추가
}
- @AutoConfigureMockMvc
- MockMvc 테스트를 위해 선언합니다.
- MockMvc 클래스
- 실제 객체와 비슷하지만, 테스트에 필요한 기능만 가지는 가짜 객체입니다.
- MockMvc 객체를 이용하면, 웹 브라우저에서 요청을 하는 것처럼 테스트할 수 있습니다.
- @BeforeEach
- 로그인 진행을 위해 모든 테스트 전에 회원 가입을 해놓습니다.
다음은 로그인 성공 테스트코드 입니다.
@Test
public void 로그인_성공() throws Exception {
String email = "aaa@naver.com";
String password = "123456789";
mockMvc.perform(formLogin().userParameter("email")
.loginProcessingUrl("/login")
.user(email).password(password))
.andExpect(SecurityMockMvcResultMatchers.authenticated());
}
- formLogin을 하고, userParameter를 email(유일값)으로 합니다.
- userParameter()를 이용해 이메일을 아이디로 세팅하고 로그인 URL에 요청합니다.
- .andExpect(SecurityMockMvcResultMatchers.authenticated());
- 로그인이 성공해 인증되었다면, 테스트코드가 통과합니다.
다음은 로그인 실패 - 비밀번호 틀림 테스트코드 입니다.
@Test
public void 로그인_실패_비밀번호_틀림() throws Exception {
String email = "aaa@naver.com";
mockMvc.perform(formLogin().userParameter("email")
.loginProcessingUrl("/login")
.user(email).password("12345"))
.andExpect(SecurityMockMvcResultMatchers.unauthenticated());
}
- .andExpect(SecurityMockMvcResultMatchers.unauthenticated())
- 회원가입 시 입력한 비밀번호가 아닌 다른 비밀번호로 로그인을 시도하면, 인증되지 않은 결과 값이 출력됩니다.
다음은 로그인 실패 - 이메일 틀림 테스트코드 입니다.
@Test
public void 로그인_실패_이메일_틑림() throws Exception {
String email = "bbb@naver.com";
String password = "123456789";
mockMvc.perform(formLogin().userParameter("email")
.loginProcessingUrl("/login")
.user(email).password(password))
.andExpect(SecurityMockMvcResultMatchers.unauthenticated());
}
결과는
모두 통과합니다.
다음은 MemberControllerTest를 해보겠습니다.
✔️ MemberControllerTest
먼저 기본 틀 입니다.
@SpringBootTest
@AutoConfigureMockMvc // MockMvc 테스트를 위해 선언
@Transactional
class MemberControllerTest {
@Autowired
MemberRepository memberRepository;
@Autowired
private MemberService memberService;
@Autowired
private MockMvc mockMvc;
@Autowired
PasswordEncoder passwordEncoder;
@Autowired
EntityManager em;
@Autowired
private ObjectMapper objectMapper;
@AfterEach
private void after(){
em.clear();
}
private void clear(){
em.flush();
em.clear();
}
@BeforeEach
private void init(){
memberRepository.save(Member.builder()
.password(passwordEncoder.encode("123456789"))
.nickname("ummchicken")
.email("aaa@naver.com")
.role(Role.USER)
.build());
clear();
}
// 테스트코드 추가
}
- ObjectMapper
- ObjectMapper를 이용하면 JSON을 Java 객체로 변환할 수 있고, 반대로 Java 객체를 JSON 객체로 serialization 할 수 있습니다.
다음은 회원정보 수정 - 닉네임 변경 테스트코드입니다.
@Test
@WithMockUser(roles = "USER")
public void 회원정보_수정_성공_닉네임변경() throws Exception {
String updateNickname = "ummPizza";
Member member = memberRepository.findByEmail("aaa@naver.com").orElseThrow(
() -> new IllegalArgumentException("회원이 존재하지 않습니다.")
);
String memberId = String.valueOf(member.getId());
Map<String, String> input = new HashMap<>();
input.put("password", "123456789");
input.put("nickname", updateNickname);
mockMvc.perform(put("/member/" + memberId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(input)))
.andExpect(status().isOk())
.andDo(print());
// then
List<Member> all = memberRepository.findAll();
assertThat(all.get(0).getNickname()).isEqualTo(updateNickname);
}
- @WithMockUser(roles = "USER")
- 현재 인증된 사용자의 Role를 USER로 세팅합니다.
- 즉, USER 권한을 가진 사용자가 API를 요청하는 것입니다.
- mvc.perform
- 생성된 MockMvc를 통해 API를 테스트 합니다.
- 본문(Body) 영역은 문자열로 표현하기 위해 ObjectMapper를 통해 문자열 JSON으로 변환합니다.
- put으로 회원정보 수정 API를 테스트 합니다.
- .contentType(MediaType.APPLICATION_JSON)
- Mock 요청의 미디어 타입을 지정합니다.
- objectMapper.writeValueAsString(input)
- Map을 Json 문자열로 바꿉니다.
- andExpect()
- 응답을 검증합니다.
- 상태코드 (status())
- isOk() : 200
- isNotFound() : 404
- isMethodNotAllowed() : 405
- isInternalServerError() : 500
- is(int status) : status 상태 코드
- andDo(print())
- 요청/응답 전체 메세지를 확인할 수 있습니다.
궁금하니까 ObjectMapper의 writeValueAsString()을 찾아 보겠습니다.
(번역)
모든 Java 값을 문자열로 직렬화하는 데 사용할 수 있는 메서드입니다.
StringWriter로 writeValue(Writer, Object)를 호출하고 String을 구성하는 것과 기능적으로 동일하지만 더 효율적입니다.
다음은 비밀번호 변경 테스트코드 입니다.
@Test
@WithMockUser(roles = "USER")
public void 회원정보_수정_성공_비밀번호변경() throws Exception {
String updatePassword = "1234567777777";
Member member = memberRepository.findByEmail("aaa@naver.com").orElseThrow(
() -> new IllegalArgumentException("회원이 존재하지 않습니다.")
);
String memberId = String.valueOf(member.getId());
Map<String, String> input = new HashMap<>();
input.put("password", updatePassword);
input.put("nickname", "ummchicken");
mockMvc.perform(put("/member/" + memberId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(input)))
.andExpect(status().isOk())
.andDo(print());
}
다음은 회원정보 삭제 테스트 코드입니다.
@Test
@WithMockUser(roles = "USER")
public void 회원정보_삭제_성공() throws Exception {
Member member = memberRepository.findByEmail("aaa@naver.com").orElseThrow(
() -> new IllegalArgumentException("회원이 존재하지 않습니다.")
);
String memberId = String.valueOf(member.getId());
mockMvc.perform(delete("/member/" + memberId))
.andExpect(status().isOk())
.andDo(print());
Assertions.assertThat(memberRepository.findByEmail("aaa@naver.com").isEmpty());
}
- 회원을 delete했으니, aaa@naver.com의 회원은 없어야 합니다.
결과는
모두 성공합니다.
MemberControllerTest를 짜는 과정에서 삽질을 좀 많이 했습니다.
비록 테스트코드는 통과되긴 하지만,
제대로 짰는지 모르겠네요;;;
처음엔 통과를 못했었습니다.
테스트가 통과 되어서 상태코드가 200이어야 하지만
302가 났다는 겁니다.
302는 요청한 리소스가 임시적으로 새로운 URL로 이동했음을 나타냅니다.
구글링을 열심히 해봐도... 명확한 해답은 못찾았습니다.
그러다 문득, 권한이 없어서 디폴트페이지로 리다이렉트 하는 것이 아닐까 생각이 들었습니다.
저 혼자 권한 설정 때문인 것 같다는 결론이 났습니다.
분명히 .role(Role.USER) 코드로 role를 user로 설정했는데...
권한 설정이 안 됐나?해서
@WithMockUser(roles = "USER")를 추가하니 통과 됩니다.
음...
두 개가 별개인가 봅니다.
아직 연구가 더 필요한 거 같습니다.
- Spring Security 로그인 구현 끝(은 아님. 계속 연구 중) -
'프로젝트' 카테고리의 다른 글
Spring Security로 회원가입을 해보자 (2) - SecurityConfig 및 회원가입 (0) | 2023.03.02 |
---|---|
Spring Security로 회원가입을 해보자 (1) - 회원 엔티티 설계 (0) | 2023.03.01 |
JPA 1 : N 관계를 분석해보자 (0) | 2023.02.27 |
스프링 시큐리티를 분석해보자 (0) | 2023.02.25 |
Session 기반 인증과 Token 기반 인증의 차이가 뭘까? (2) | 2023.02.20 |