이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.
MemberRepository
/**
* 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려
*/
@Slf4j
@Repository
public class MemberRepository {
private static Map<Long, Member> store = new HashMap<>(); //static 사용
private static long sequence = 0L; //static 사용
public Member save(Member member) {
member.setId(++sequence);
log.info("save: member={}", member);
store.put(member.getId(), member);
return member;
}
public Member findById(Long id) {
return store.get(id);
}
public Optional<Member> findByLoginId(String loginId) {
// List<Member> all = findAll();
// for (Member m : all){
// if(m.getLoginId().equals(loginId)){
// return m;
// }
// }
// return m;
return findAll().stream()
.filter(m -> m.getLoginId().equals(loginId))
.findFirst();
}
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
public void clearStore() {
store.clear();
}
}
MemberController
@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/add")
public String addForm(@ModelAttribute("member") Member member) {
return "members/addMemberForm";
}
@PostMapping("/add")
public String save(@Valid @ModelAttribute Member member, BindingResult result) {
if (result.hasErrors()) {
return "members/addMemberForm";
}
memberRepository.save(member);
return "redirect:/";
}
}
LoginService
@Service
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
/**
* @return null이면 로그인 실패
*/
public Member login(String loginId, String password) {
// Optional<Member> findMemberOptional = memberRepository.findByLoginId(loginId);
// Member member = findMemberOptional.get();
// if(member.getPassword().equals(password)){
// return member;
// }else {
// return null;
// }
return memberRepository.findByLoginId(loginId)
.filter(m -> m.getPassword().equals(password))
.orElse(null);
}
}
LoginController
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;
@GetMapping("/login")
public String loginForm(@ModelAttribute("loginForm") LoginForm form) {
return "login/loginForm";
}
@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
log.info("login? {}", loginMember);
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
//로그인 성공 처리
//쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 모두 종료)
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
return "redirect:/";
}
@PostMapping("/logout")
public String logout(HttpServletResponse response) {
expireCookie(response, "memberId");
return "redirect:/";
}
private void expireCookie(HttpServletResponse response, String cookieName) {
Cookie cookie = new Cookie(cookieName, null);
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
로그인 컨트롤러는 로그인 서비스를 호출해서 로그인에 성공하면 홈 화면으로 이동하고, 로그인에 실패하면 bindingResult.reject() 를 사용해서 글로벌 오류( ObjectError )를 생성한다.
그리고 정보를 다시 입력하도록 로그인 폼을 뷰 템플릿으로 사용한다.
영속 쿠키와 세션 쿠키
쿠키에는 영속 쿠키와 세션 쿠키가 있다.
- 영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지
- 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시 까지만 유지
브라우저 종료시 로그아웃이 되길 기대하므로, 우리에게 필요한 것은 세션 쿠키이다.
쿠키 생성 로직
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
로그인에 성공하면 쿠키를 생성하고 HttpServletResponse 에 담는다.
쿠키 이름은 memberId 이고, 값은 회원의 id 를 담아둔다. 웹 브라우저는 종료 전까지 회원의 id 를 서버에 계속 보내줄 것이다.
로그아웃 기능
로그아웃 방법은 다음과 같다.
- 세션 쿠키이므로 웹 브라우저 종료시
- 서버에서 해당 쿠키의 종료 날짜를 0으로 지정
cookie.setMaxAge(0);
해당 쿠키의 유효시간을 0으로 만듦으로써 만료되게 만든다.
홈 화면 - 로그인 처리
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {
private final MemberRepository memberRepository;
//@GetMapping("/")
public String home() {
return "home";
}
@GetMapping("/")
public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {
if (memberId == null) {
return "home";
}
//로그인
Member loginMember = memberRepository.findById(memberId);
if (loginMember == null) {
return "home";
}
//쿠키가 유효하다면
model.addAttribute("member", loginMember);
return "loginHome";
}
}
@CookieValue
@CookieValue는 주로 사용자 세션 관리, 광고 추적, 사용자 설정 저장 등의 목적으로 쿠키를 사용할 때 유용하다.
- value (또는 name): 바인딩할 쿠키의 이름을 지정한다.
- required: 이 속성이 true로 설정되어 있고 지정된 이름의 쿠키가 존재하지 않을 경우, 예외를 발생시킨다.
기본값은 true이지만, false로 설정하면 쿠키가 없는 경우 메소드 파라미터를 null 또는 Optional.empty()로 처리할 수 있다.
로그인 하지 않은 사용자도 홈에 접근할 수 있기 때문에 required = false 를 사용한다.
홈 화면에 화원 관련 정보도 출력해야 해서 member 데이터도 모델에 담아서 전달
쿠키와 보안문제
쿠키 값은 임의로 변경할 수 있다.
쿠키에 보관된 정보는 훔쳐갈 수 있다.
이 정보가 웹 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서 서버로 전달된다.
쿠키의 정보가 나의 로컬 PC에서 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있다. 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다.
해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있다.
대안
쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식한다. 그리고 서버에서 토큰을 관리한다.
토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능 해야 한다.
해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게(예: 30분) 유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 된다.
보안상 중요한 데이터는 쿠키대신 세션을 이용하거나 쿠키와 세션을 적절히 섞어서 사용해야 한다.
'Java Category > Spring' 카테고리의 다른 글
[Spring MVC] 필터, 인터셉터(로그인 처리 관련) (1) | 2024.03.18 |
---|---|
[Spring MVC] 세션을 이용한 로그인 (2) | 2024.03.17 |
[Spring MVC] 검증(Validation) - Bean Validation (1) | 2024.03.14 |
[Spring MVC] 검증(Validation) - Validator 분리 (0) | 2024.03.13 |
[Spring MVC] 검증(Validation) - 오류 코드와 메시지 처리 (1) | 2024.03.12 |