30. Spring Security : 로그인하기

Feb 13, 2024
30. Spring Security : 로그인하기

1. SecurityConfig에서 login 설정하기

  • loginProcessingUrl : security가 가지고 있는 login 만들기
우리가 “/login”을 안 만들어도 됨
  • defaultSuccessUrl : 로그인 성공시 어디로 리다이렉션 할 것인지 설정
  • failureUrl : 로그인 실패시 어디로 리다이렉션 할 것인지 설정
  • mustache화면에서 Form태그 → /login, post 요청 → username, password를 submit
→ submit하는 순간 Security가 /login요청을 filter에서 가로챔
→ UserDetailsService에 있는 특정 메서드 호출
  • 기본적으로 Security 자체가 login을 구현하고 있는 화면(특정 메서드)을 커스터 마이징 할 예정
notion image
  • 이미 IoC에 UserDetailsService 등록되어있음
package shop.mtcoding.blog._core.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.web.SecurityFilterChain; @Configuration// 메모리에 띄우기 위한 문법 public class SecurityConfig { // 인증과 상관없이 열어야 하는 주소 // 주소 설계를 잘해야 함 @Bean public WebSecurityCustomizer ignore(){ return w -> w.ignoring().requestMatchers("/board/*", "/static/**", "/h2-console/**"); } @Bean SecurityFilterChain configure(HttpSecurity http) throws Exception { // 주소로 필터링 : 인증이 필요한 페이지를 주소로 구분 http.authorizeHttpRequests(a -> { a.requestMatchers("/user/updateForm", "/board/**").authenticated() // 인증이 필요한 페이지 .anyRequest().permitAll(); // 인증이 필요없는 페이지 }); // 기본 주소를 우리가 만든 페이지로 변경함 http.formLogin(f -> { // security가 들고있는 페이지를 사용할 것 f.loginPage("/loginForm").loginProcessingUrl("/login").defaultSuccessUrl("/") .failureUrl("/loginForm"); // 실패하면 이동 }); return http.build(); // 코드의 변경이 없으면 부모 이름(추상적)으로 리턴할 필요 없음 } }
 

2. config 패키지에 Security 패키지 만들고 MyLoginService class 만들기

  • UserDetailsService를 implement 해야 함
spring security 라이브러리를 적용하면
최초에 스프링이 실행될 때 UserDetailsService가 new 되서 IoC 컨테이너에 등록이 되어있음
implement해서 메서드 구현하면 메모리에 안뜸 → @Service 붙이기
@Service : 내부에 component가 있어서 component scane이 됨
MyLoginService가 new가 되서 IoC 컨테이너에 들어감
기존에 UserDetailsService와 타입을 일치시켜 덮어 씌움
→ 무력화 시키고 MyLoginService가 떠있음
/login의 post(주소) 요청하는 순간 loadUserByUsername()가 실행됨
  • loadUserByUsername()의 내부 : /login해서 구현했던 내용들
username 받아서 DB에 조회해서 password랑 동일한지 비교하고 session을 만들어주면 됨
  • 로그인 실패 시 로그인 진행하던걸 취소하고 알아서 응답해줌
-> 반환할 페이지를 알려줘야 함
  • 조건 : post 요청, "/login"요청, x-www-form-urlencoded, 키 값 username, password
  • DB에서 조회한 객체를 넣어주지 않으면 오류가 남
  • org.springframework 패키지 : Spring 프레임워크 자체의 핵심 라이브러리와 클래스를 포함
스프링의 핵심 기능 및 유틸리티 클래스 등이 포함
  • shop.mtcoding 패키지 : 특정 프로젝트나 응용 프로그램의 사용자 정의 패키지
프로젝트에서 정의한 사용자 클래스 또는 기타 관련 클래스를 포함
package shop.mtcoding.blog._core.config.security; import lombok.AllArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import shop.mtcoding.blog.user.User; import shop.mtcoding.blog.user.UserRepository; /* * 조건 * post 요청 * "/login"요청 * x-www-form-urlencoded * 키값이 username, password*/ @RequiredArgsConstructor @Service public class MyLoginService implements UserDetailsService { private final UserRepository userRepository; // DI @Override // security가 username만 줌 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { System.out.println("loadUserByUsername: " + username); // username이 있는지 조회하기 User user = userRepository.findByUsername(username); if (user == null) { return null; // 로그인 진행하던걸 취소하고 알아서 응답해줌 -> 반환할 페이지를 알려줘야함 } else { return new MyLoginUser(user); // DB에서 조회한 객체를 넣어주지 않으면 오류가 남 } } }
 

3. UserRepository에 findByUsername() 만들기

1) 세션에 저장하기 위해서 UserDetail로 리턴을 받는 이유는? session에 저장하기 위함
리턴 타입이 UserDetails라 User를 리턴할 수 없음 → User를 UserDetails로 커스터 마이징 해야 함
void라면 나보고 저장하라는 이야기임 / 의도를 파악해야 함
로그인 시스템에 대한 이해가 필요함
클라이언트한테 username과 password를 받음 → DB에 조회 → 정상 : session에 담음
2) object가 아니라 UserDetails로 고정한 이유는? UserDetails 인터페이스
Object타입이면 user를 리턴 받을 수 있으나 session에 object가 저장되어 사용할 수도 있음
내가 무슨 이름으로 객체를 만들지 라이브러리를 만드는 사람은 알 수가 없음
- getUsername() : 사용자의 식별자인 사용자명
- getPassword() : 사용자의 암호화된 비밀번호
- getAuthorities() : 사용자가 가지고 있는 권한 / retuen타입 : collection
여러가지 권한을 가질 수도 있음
- isEnabled() : 사용자 계정이 활성화되었는지 여부 / 활성화 = true
- isAccountNonExpired() : 사용자 계정의 만료 여부 / 만료 = false / 법적으로 중요함
- isAccountNonLocked() : 사용자 계정의 잠김 여부 / 시도가 여러 번 있을 경우 = false
- isCredentialsNonExpired() : 사용자 계정의 자격 증명(비밀번호)의 만료 여부 / 만료 = false
보안상의 이유로 정기적으로 비밀번호를 변경해야하는 것
3) password를 안 받는 이유는?
- 우린 조회만 해서 주면 session에 넣기 직전에 getPassword() 실행
return 되는 값이랑 User객체랑 맞는지 알아서 비교해서 인증해줌
비교해서 맞으면 session이 만들어짐
package shop.mtcoding.blog.user; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import jdk.swing.interop.SwingInterOpUtils; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog.board.Board; import shop.mtcoding.blog.board.BoardRequest; @Repository // IoC에 new하는 방법 public class UserRepository { // DB에 접근할 수 있는 매니저 객체 // 스프링이 만들어서 IoC에 넣어둔다. // DI에서 꺼내 쓰기만 하면된다. private EntityManager em; // 컴포지션 // 생성자 주입 (DI 코드) public UserRepository(EntityManager em) { this.em = em; } @Transactional // db에 write 할때는 필수 public void save(UserRequest.JoinDTO requestDTO){ Query query = em.createNativeQuery("insert into user_tb(username, password, email, created_at) values(?,?,?, now())"); query.setParameter(1, requestDTO.getUsername()); query.setParameter(2, requestDTO.getPassword()); query.setParameter(3, requestDTO.getEmail()); query.executeUpdate(); } public User findByUsernameAndPassword(UserRequest.LoginDTO requestDTO) { Query query = em.createNativeQuery("select * from user_tb where username=? and password=?", User.class); // 알아서 매핑해줌 query.setParameter(1, requestDTO.getUsername()); query.setParameter(2, requestDTO.getPassword()); try { // 내부적으로 터지면 터지는 위치를 찾아서 내가 잡으면 됨 User user = (User) query.getSingleResult(); return user; } catch (Exception e) { return null; } } public User findByIdAndEmail(int id) { Query query = em.createNativeQuery("select username, email from user_tb where id=?"); query.setParameter(1, id); try { User user = (User) query.getSingleResult(); return user; } catch (Exception e) { return null; } } @Transactional public void userUpdate(UserRequest.UpdateDTO requestDTO, int id){ Query query = em.createNativeQuery("update user_tb set password=? where id = ?"); query.setParameter(1,requestDTO.getPassword() ); query.setParameter(2, id); query.executeUpdate(); System.out.println("query:" + query); } //security가 username만 제공 public User findByUsername(String username) { Query query = em.createNativeQuery("select * from user_tb where username=? ", User.class); // 알아서 매핑해줌 query.setParameter(1, username); try { // 내부적으로 터지면 터지는 위치를 찾아서 내가 잡으면 됨 User user = (User) query.getSingleResult(); return user; } catch (Exception e) { return null; } } }
 

4. MyLoginUser 클래스 만들기

  • User 자체를 implement해서 UserDetails로 맞춰서 같은 타입으로 만들어 담을 수 있음
→ 강제성 부여되어 메서드들을 구현해야함
테이블인데 복잡해짐
  • 세션에 저장되는 오브젝트들을 관리하는 클래스
  • 클라이언트한테 받은 password와 비교하기 위해 DB에서 조회된 값이 필요함
컴포지션 해야 함 / 생성자 주입
package shop.mtcoding.blog._core.config.config; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import shop.mtcoding.blog.user.User; import java.util.Collection; // 세션에 저장되는 오브젝트 @Getter @RequiredArgsConstructor public class MyLoginUser implements UserDetails { private final User user; // 컴포지션 - 결합 @Override public String getPassword() { return user.getPassword(); // DB에서 조회된 값을 넣어야함 → 컴포지션 해야 함 } @Override public String getUsername() { return user.getUsername(); // DB에서 조회된 값을 넣어야함 } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } }
 

5. MyLoginService에서 머스태치에서만 sessionUser가져오기

package shop.mtcoding.blog._core.config.security; import jakarta.servlet.http.HttpSession; import lombok.AllArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import shop.mtcoding.blog.user.User; import shop.mtcoding.blog.user.UserRepository; /* * 조건 * post 요청 * "/login"요청 * x-www-form-urlencoded * 키값이 username, password*/ @AllArgsConstructor @Service public class MyLoginService implements UserDetailsService { private final UserRepository userRepository; private final HttpSession session; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { System.out.println("loadUserByUsername: " + username); User user = userRepository.findByUsername(username); if (user == null) { System.out.println("user는 null"); return null; // 로그인 진행하던걸 취소하고 알아서 응답해줌 -> 반환할 페이지를 알려줘야함 } else { System.out.println("user를 찾았어요"); session.setAttribute ("sessionUser", user); // 머스태치에서만 가져오기 return new MyLoginUser(user); // securityContextHolder에 저장됨 } } }
notion image
  • MyLoginUser가 null이라서 오류가 남
notion image
 

6. 머스태치에서 사용가능한 세션 정보

  • session안에 Spring Security Context가 있고 안에 Security Context 안에 Authentication이 있고
MyLoginUser(UserDetails 타입) 안에 User가 있음
@AuthenticarionPrincipal로 꺼내 사용하면 됨
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null) { return (UserDetails) authentication.getPrincipal(); }
public String getUserDetails(@AuthenticationPrincipal UserDetails userDetails) { // 현재 사용자의 UserDetails를 사용하여 작업 수행 String username = userDetails.getUsername();
  • 더 쉽게 하는 방법
내가 loadByUsername에서 만들어질 때
sessionUser를 만들어서 로그아웃 시 invalidate()할 때 둘 다 날리면 됨
  • 다른 방법
notion image
 

6. Security의 장점

  • 앞에서 주소로 필터링 해줌
  • 로그인시 해쉬 비번 자동으로 해줌
  • CSRF 인증 보안적인 것을 막아줌
 

7. 알아야 하는 개념

  • 의존성 주입
  • session
  • login system
  • security session의 저장 위치
 

8. 기존 controller에서 인증 부분 수정하기

  • 주소 설계할 때 인증이 필요한 주소는 앞에 Entity이름을 안 붙임
  • 실제는 AuthContoller 클래스를 따로 만들어서 인증이 필요한 모든 controller를 넣음
서버를 따로 만들어서 마이크로하게 째서 서버를 여러 개 띄움
  • 인증이 필요하면 /api 붙이면 /api/**만 붙이면 됨
package shop.mtcoding.blog.user; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import lombok.AllArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import shop.mtcoding.blog._core.config.security.MyLoginUser; import shop.mtcoding.blog.board.Board; import shop.mtcoding.blog.board.BoardRequest; @AllArgsConstructor @Controller public class UserController { // fianl 변수는 반드시 초기화 되어야 함 private final UserRepository userRepository; // null private final HttpSession session; @GetMapping("/loginForm") // view만 원함 public String loginForm() { return "user/loginForm"; } // 원래는 get요청이나 예외 post요청하면 됨 // 민감한 정보는 쿼리 스트링에 담아보낼 수 없음 //원래는 get요청이나 예외 post요청하면 됨 //민감한 정보는 쿼리 스트링에 담아보낼 수 없음 // @PostMapping("/login") // public String login(UserRequest.LoginDTO requestDTO) { // // // 1. 유효성 검사 // if (requestDTO.getUsername().length() < 3) { // return "error/400"; // } // // // 2. 모델 필요 select * from user_tb where username=? and password=? // User user = userRepository.findByUsernameAndPassword(requestDTO); // DB에 조회할때 필요하니까 데이터를 받음 // if (user == null) { // return "error/401"; // } else { // session.setAttribute("sessionUser", user); // return "redirect:/"; // } // } @GetMapping("/joinForm") // view만 원함 public String joinForm() { return "user/joinForm"; } @PostMapping("/join") public String join(UserRequest.JoinDTO requestDTO) { System.out.println(requestDTO); // 1. 유효성 검사 if (requestDTO.getUsername().length() < 3) { return "error/400"; } userRepository.save(requestDTO); // 모델에 위임하기 return "redirect:/loginForm"; //리다이렉션불러놓은게 있어서 다시부른거 } @GetMapping("/updateForm") // view만 원함 public String updateForm(HttpServletRequest request, @AuthenticationPrincipal MyLoginUser myLoginUser) { User user = userRepository.findByUsername(myLoginUser.getUsername()); request.setAttribute("user", user); return "user/updateForm"; } @PostMapping("/user/update") public String updateUser(UserRequest.UpdateDTO requestDTO, HttpServletRequest request) { // 세션에서 사용자 정보 가져오기 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) { return "redirect:/loginForm"; // 로그인 페이지로 리다이렉트 } // 비밀번호 업데이트 userRepository.userUpdate(requestDTO, sessionUser.getId()); session.setAttribute("sessionUser", sessionUser); return "redirect:/"; // 홈 페이지로 리다이렉트 } @GetMapping("/logout") public String logout() { // 1번 서랍에 있는 uset를 삭제해야 로그아웃이 됨 session.invalidate(); // 서랍의 내용 삭제 return "redirect:/"; } }
package shop.mtcoding.blog.board; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; import shop.mtcoding.blog._core.config.security.MyLoginUser; import shop.mtcoding.blog.user.User; import java.util.HashMap; import java.util.List; @RequiredArgsConstructor @Controller public class BoardController { private final HttpSession session; private final BoardRepository boardRepository; // ?title=제목1&content=내용1 // title=제목1&content=내용1 @PostMapping("/board/{id}/update") public String update(@PathVariable int id, BoardRequest.UpdateDTO requestDTO, @AuthenticationPrincipal MyLoginUser myLoginUser) { // 2. 권한 체크 Board board = boardRepository.findById(id); if (board.getUserId() != myLoginUser.getUser().getId()) { return "error/403"; } // 3. 핵심 로직 // update board_tb set title = ?, content = ? where id = ?; boardRepository.update(requestDTO, id); return "redirect:/board/" + id; } @GetMapping("/board/{id}/updateForm") public String updateForm(@PathVariable int id, HttpServletRequest request, @AuthenticationPrincipal MyLoginUser myLoginUser) { // 2. 권한 없으면 나가 // 모델 위임 (id로 board를 조회) Board board = boardRepository.findById(id); if (board.getUserId() != myLoginUser.getUser().getId()) { return "error/403"; } // 3. 가방에 담기 request.setAttribute("board", board); return "board/updateForm"; } @PostMapping("/board/{id}/delete") public String delete(@PathVariable int id, HttpServletRequest request, @AuthenticationPrincipal MyLoginUser myLoginUser) { Board board = boardRepository.findById(id); if (board.getUserId() != myLoginUser.getUser().getId()) { request.setAttribute("status", 403); request.setAttribute("msg", "게시글을 삭제할 권한이 없습니다"); return "error/40x"; } boardRepository.deleteById(id); return "redirect:/"; } @PostMapping("/board/save") public String save(BoardRequest.SaveDTO requestDTO, HttpServletRequest request, @AuthenticationPrincipal MyLoginUser myLoginUser) { // 2. 바디 데이터 확인 및 유효성 검사 System.out.println(requestDTO); if (requestDTO.getTitle().length() > 30) { request.setAttribute("status", 400); request.setAttribute("msg", "title의 길이가 30자를 초과해서는 안되요"); return "error/40x"; // BadRequest } // 3. 모델 위임 // insert into board_tb(title, content, user_id, created_at) values(?,?,?, now()); boardRepository.save(requestDTO, myLoginUser.getUser().getId()); return "redirect:/"; } @GetMapping("/") public String index(HttpServletRequest request) { List<Board> boardList = boardRepository.findAll(); request.setAttribute("boardList", boardList); return "index"; } // /board/saveForm 요청(Get)이 온다 @GetMapping("/board/saveForm") public String saveForm() { return "board/saveForm"; } @GetMapping("/board/{id}") public String detail(@PathVariable int id, HttpServletRequest request, @AuthenticationPrincipal MyLoginUser myLoginUser) { // 1. 모델 진입 - 상세보기 데이터 가져오기 BoardResponse.DetailDTO responseDTO = boardRepository.findByIdWithUser(id); boolean pageOwner; if (myLoginUser == null) { pageOwner = false; } else { int 게시글작성자번호 = responseDTO.getUserId(); int 로그인한사람의번호 = myLoginUser.getUser().getId(); pageOwner = 게시글작성자번호 == 로그인한사람의번호; } request.setAttribute("board", responseDTO); request.setAttribute("pageOwner", pageOwner); return "board/detail"; } }
Share article
RSSPowered by inblog