-
스프링부트+JPA 블로그 프로젝트 06 로그인(트랜잭션/시큐리티)스프링/스프링부트+JPA - 블로그 2021. 8. 30. 20:29
유투버 '데어프로그래밍'님 강의 참조
▲ DB 격리 수준
☞ READ COMMIT - 오라클용
* 사전 지식
- Transaction --> 일이 처리되기 위한 가장 작은 단위
- 이러한 작은 트랜잭션들이 모여 하나의 트랜잭션을 만들고 이 하나의 트랜잭션을 처리하는게 스프링의 Service
- 그렇다면 트랜잭션에서의 DB격리 수준이란 무슨 말일까?
오라클에서는 COMMIT전의 데이터는 바뀌지 않는다(격리 된다)라고 생각하면 쉬울 것 같다.
예를 들어 보자
EMPNO NAME 10 홍길동 11 임꺽정 위의 테이블에서 A라는 사람이 13:00 PM에 번호 11번의 이름을 '장보고'라고 UPDATE문을 날렸다.
EMPNO NAME 10 홍길동 11 장보고(13:00PM) 이때 B라는 사람도 동시에 13:00PM에 오라클에에 SELECT를 하게 되면 B는 COMMIT전까지 임꺽정을 받게 되는 로직이다. 즉, 오라클의 DB 격리 수준이란 DB에서 UNDO라는 영역에 원데이터를 나두고(격리) COMMIT이 되기전까지는 UNDO에서 데이터를 계속해서 주고 COMMIT 이후 바뀐 데이터를 주게 되는 것.
- READ COMMIT의 문제점
1) 데이터의 정합성이 깨진다. 처음 데이터를 보고 그 데이터값을 기대하고 읽는 B가 나중에는 A의 예상치 못한 커밋으로 다른 데이터를 받게되는 것. 이를 다른 말로 정합성이 깨진다라고 부름.
2) 또한 READ COMMIT으로 원데이터가 보였다 안보였다 하는 현상을 '팬텀 리드' 라고 부름
☞ REPEATABLE READ - MySQL
- READ COMMIT이 가지고 있는 팬텀리드현상을 REPEATABLE READ으로 해결 가능하다. (MySQL의 기본 격리 수준)
- 기본적으로 정합성이 깨지는 일이 없다.
- 오라클과 반대개념으로 각각의 트랜잭션에 번호를 매기고 자기의 트랜잭션이 가지고 있는 UNDO의 로그 번호로 구별을 하게되고 자기보다 늦은 UNDO 로그는 들고 오지 않는다.
→ 즉 DB격리수준과 정합성을 모르면 데이터를 요청과 응답을할때 트랜잭션이 들어가야하는지 아닌지 모르게 되는 것
▲ 스프링부트의 트랜잭션
- 스프링 시작 -> 내장톰켓 시작(서버 작동) -> WEB.XML 읽음 -> CONTEXT.XML 읽어서 DB연결 여부 확인 -> REQUEST/RESPONSE 처리 대기
- REQUEST -> WEB.XML(JDBC연결/트랜잭션시작/영속성컨텍스트 시작) -> 필터 -> CONTROLLER -> SERVICE -> REPOSITORY -> DB(영속성컨텍스트) -> DATA/HTML로 응답(JDBC종료/트랜잭션종료/영속성컨텍스트종료)
영속성 컨텍스트 시작지점과 종료지점도 동일하다 - 하지만 이 스프링부트의 트랜잭션에는 문제가 있다고 한다!
1) 종료의 시점이 컨트롤러로 다시가야하는 문제
2) JDBC/트랜잭션/영속성컨텍스트의 시작 지점(단순 요청인데 셀렉트/트랜잭션이 왜 필요하나?)
3) 이 두개로인한 데이터베이스의 부하
4) fetch시에 Lazy/Eager과 상관없는 데이터 유지
- 이 문제를 해결하는것이 스프링부트의 OSIV(Open-in-view)
false로 같이 종료 가능 데이터가 프록시 상태로 유지된다! - 스트링부트 2.0부터 추가된 기능으로 JDBC/트랜잭션/영속성컨텍스트의 시작과 종료시점을 필요한곳으로 최대한 미루고 영속성컨텍스트 프리젠테이션 시간만 컨트롤러 미뤄서 Lazy Loading에 필요한 객체들을 Persistence 상태로 유지 가능하도록 해주는 것
1) 시큐리티 적용 전 전통적 로그인 방식 해보기
* 로그인은 단순 SELECT! / 회원가입과 동일 개념
- loginForm에서도 버튼 위치와 js 세팅
</form> <button id="btn-login" class="btn btn-primary">로그인</button> </div> <script src="/blog/js/user.js"></script>
- Login 용 JS
let index = { //let_this = this; -> function(){} 용 init:function(){ $("#btn-save").on("click",()=>{ //function(){} 대신 ()=>{}를 써서 전역변수 this를 바인딩 this.save(); }); $("#btn-login").on("click",()=>{ //function(){} 대신 ()=>{}를 써서 전역변수 this를 바인딩 this.login(); }); }, ... login:function(){ //this.save()의 save //alert("user.js의 save 함수 호출됨"); let data = { username:$("#username").val(), password:$("#password").val() }; //console.log(data); $.ajax({ //ajax 기본 호출은 비동이 //오브젝트가 들어오는 곳 //통신 수행(회원가입요청) type:"post", url:"/blog/api/user/login", data:JSON.stringify(data),//HTTP BODY DATA contentType:"application/json; charset=utf-8", //body데이터의 타입 dataType:"json" //서버에서 받을 데이터 형식, 즉 json으로 던지고 서버를위해 자동 파싱 = JSON->JS }).done(function(resp){ //위의 데이터가 js로 바뀌고 파라미터로 사용 가능 //통신이 정상이면 done alert("로그인 성공!"); location.href="/blog"; }).fail(function(error){ //통신이 비정상이면 fail alert(JSON.stringify(error)); }); }
- UserApiController에 추가
// @Autowired // private HttpSession session; @PostMapping("/api/user/login") public ResponseDto<Integer> login(@RequestBody User user, HttpSession session){ System.out.println("UserApiController의 login() 호출"); User principal = userService.login(user); if(principal != null) { session.setAttribute("principal", principal); } return new ResponseDto<Integer>(HttpStatus.OK.value(),1); }
- 로그인을 한 클라이언트에게 세션을 반드시 줘야한다. 세션 주는 파라미터를 기억하자!
- 세션은 @Autowired로도 세팅이 가능하다
- UserService 추가
//로그인 @Transactional(readOnly = true) // 트랜잭션 정합성을 종료시까지 유지 public User login(User user) { return userRepository.findByUsernameAndPassword(user.getUsername(), user.getPassword()); }
- 레파지토리 메소드에는 로그인이 없다 그래서 따로 만들어 줘야 한다.
public interface UserRepository extends JpaRepository<User, Integer>{ //JPA Naming query 전략 User findByUsernameAndPassword(String username, String password); //둘다 가능 @Query(value="SELECT * FROM user WHERE username = ? AND password = ?", nativeQuery = true) User login(String username, String password); }
- JPA에서 findBy.... 를 보게되면 자동적으로 쿼리문을 findBy 뒤에 오는 이름으로 만든다
'SELECT * FROM user WHERE username = ? AND password = ?';
물음표에는 파라미터들이 순차적으로 들어오게 된다 - 혹은 쿼리 어노테이션으로 직접 짤 수도 있다 (nativeQuery 생략 가능)
▲ 회원가입과 정말 비슷한 로직으로 진행 된다. 다만 없는 메소드를 레퍼지토리에 만들고 세션을 줄 뿐
컨트롤러에서 로그인이 성공하면 1을 받고 똑같이 1이 ajax의 resp로 간다. 그리고 똑같은 로직으로 화면 이동- 회원에게는 로그인시 다른 화면이 나오도록 해야 한다.
☞ JSTL 을 써보자!
- JSTL이란?
표준 태그 라이브러리로써 JSP 개발을 단순화 하기 위한 태그 집합을 나타낸다. 많이들 아는 <% %> 식의 태그쓰기위해서는 아래 세팅이 필요하다!
<%@ taglib prefix = "c" uri = "http://java.sun.com/jsp/jstl/core" %> *pom.xml <!-- JSTL --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> </dependency>
- header.jsp 코드 추가
<div class="collapse navbar-collapse" id="collapsibleNavbar"> <c:choose> <c:when test="${empty sessionScope.principal}"> <ul class="navbar-nav"> <li class="nav-item"><a class="nav-link" href="/blog/user/loginForm">로그인</a></li> <li class="nav-item"><a class="nav-link" href="/blog/user/joinForm">회원가입</a></li> </ul> </c:when> <c:otherwise> <ul class="navbar-nav"> <li class="nav-item"><a class="nav-link" href="/blog/board/writeForm">글쓰기</a></li> <li class="nav-item"><a class="nav-link" href="/blog/user/userForm">회원정보</a></li> <li class="nav-item"><a class="nav-link" href="/blog/user/logout">로그아웃</a></li> </ul> </c:otherwise> </c:choose> </div>
로그인 성공 후 화면이 잘 바뀐다! 레퍼지토리에 만든 쿼리문도 잘된다 ▲ 시큐리티 로그인
- 권한부여를 위한 요청 주소 변경
- application.yml에 context-path 수정
- 컨트롤러
//회원 가입 화면으로 으로 이동 @GetMapping("/joinForm") public String joinForm() { return "user/joinForm"; } //로그인 화면으로 이동 @GetMapping("/loginForm") public String loginForm() { return "user/loginForm"; }
- header.jsp
<a class="navbar-brand" href="/">HOME</a> .. <li class="nav-item"><a class="nav-link" href="/loginForm">로그인</a></li> <li class="nav-item"><a class="nav-link" href="/joinForm">회원가입</a></li> <li class="nav-item"><a class="nav-link" href="/board/writeForm">글쓰기</a></li> <li class="nav-item"><a class="nav-link" href="/user/userForm">회원정보</a></li> <li class="nav-item"><a class="nav-link" href="/logout">로그아웃</a></li>
- js파일에도 /blog가 들어간 부분을 다 지워 주고 JS 스크립트가 들어간 부분에도 '/blog' 삭제!
이외의 모든 url도 잘된다 ♣ 스프링 시큐리티 테스트
- 시큐리티를 쓰기위한 pom.xml 디펜던시
<!-- 시큐리티 태그 라이브러리 --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-taglibs</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
- JSP에 시큐리티를 쓰기위한 태그라이브러리
<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>
- 시큐리티가 임베디드 되면 이제 무조건 시큐리티 로그인 페이지가 뜬다.
- 로그인 방법은 아이디는 user 그리고 비밀번호는 콘솔에 뜨는것을 입력
- 시큐리티를 걸게되면 이제 로그인시 자동 세션이 주어지는데 이때 아래 코드와 함께 property와 var을 줄 수 있게 된다.
<sec:authorize access="isAuthenticated()"> <sec:authentication property="principal" var="principal"/> </sec:authorize>
- 이 두개의 변수를 가지고 <c:when test="${empty principal}"> 로 처리도 가능하고 값을가지고 조금 더 정교한 시큐리티 로그인 처리가 가능하다
♣ 스프링 시큐리티 구성과 개념
- 주소 구성을 해야 한다. 왜냐하면 시큐리티를 쓰면 인증이 된사람과 안된사람이 들어갈수있는 페이지를 설정이 가능하기 때문이다.
- 인증이 필요없는 위치는 /auth로 구분하여 모두가 들어갈수있도록 설정, 전통적인 로그인방식 모두 주석처리
- 회원가입시의 주소는 /auth/joinProc로 통일
- loginForm.jsp의 버튼 위치를 다시 <form>태그안에 넣고 자바용 스크립트를 지운다. (이제 <form>으로 로그인 처리) -> 그리고 name을 각각의 input에 삽입
- js에도 전통 로그인방식 주석처리
- 시큐리티 기본 세팅
@Configuration //설정파일 빈 등록 방법은 이 어노테이션 @EnableWebSecurity //시큐리티 필터 기능 추가 가능 @EnableGlobalMethodSecurity(prePostEnabled = true)//특정 주소 접근이면 권한/인증 미리 체크함 public class SecurityConfig extends WebSecurityConfigurerAdapter{ @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable(); //csrf 토큰 비활성화 http .authorizeRequests() //request가 들어오면 .antMatchers("/","/auth/**", "/js/**", "/css/**", "/image/**") //그 요청이 /auth 이하면 .permitAll()//모두 허용 .anyRequest() //그 외 요청은 .authenticated() //인증을 요구 .and() // 시큐리티 공식 로그인 페이지를 대체시켜주는 코드 .formLogin() .loginPage("/auth/loginForm"); } }
- 무엇을 permit을 하고 무엇을 인증을 요구할건지 잘 생각해야하고 formLogin()이하로 로그인 페이지를 설정 할 수 있다. 즉, 이 웹사이트로 들어오면 인증을 요구하고 loginPage()이하로 이동시킨다.
- 시큐리티는 기본적으로 CSRF토큰이 없는 유저들은 블락하기때문에 .disable() 해놓고 작업하는게 좋다
♣ 비밀번호 해시화를 이용해 회원가입 처리 후 시큐리티 로그인
- 시큐리티를 걸게되면 로그인요청이 왔을때 시큐리티가 처리하도록 인가가 가능하다. 시큐리티가 로그인 처리를 맞게되고 그때부터는 시큐리티 세션에서 유저정보가 관리되어지게 된다.
- 근데 이 유저정보는 USER오브젝트 기반이기때문에 시큐리티세션에서 받지 못한다. 그래서 스프링에서 제공하는 USERDETAILS라는 타입으로 정해서 넣어줘야한다.
- 또한 시큐리티 특성상 로그인시에는 단순 숫자 1234로는 로그인이 안되게 기능이 탑재되어있다. 그래서 클라이언트가 회원가입을 할 때 암호를 해시화처리를 해야하고 이 해시화된 암호로 로그인을 해야 시큐리티에서 로그인 시켜 준다
* 해시화의 특징
- 어떠한 값을 해시화 시키면 고정길이로 문자열로 변환해서 돌려 준다.
- 해시값은 값이 변경될때마다 바뀌기 때문에 변경 유무를 확인하는데 쉽다
- 해시화를 하는 함수를 시큐리티에 추가
public class SecurityConfig extends WebSecurityConfigurerAdapter{ @Bean public BCryptPasswordEncoder encodePwd() { return new BCryptPasswordEncoder(); //값을 해쉬화를 시켜주는 함수 } ...
- 서비스에 Autowired로 해시화 함수를 걸고 로그인시 처리되도록 세팅
@Service public class UserService { @Autowired private UserRepository userRepository; @Autowired private BCryptPasswordEncoder encode; //회원가입 @Transactional public void insert(User user) { String rawPassword = user.getPassword(); // 원래 암호 String encPassword = encode.encode(rawPassword); user.setPassword(encPassword); user.setRole("user"); userRepository.save(user); }
굿 ♣ 스프링 시큐리티 마무리
- loginForm에서 form태그 수정
<form action="/auth/loginProc" method="post">
- 컨트롤러에서 처리하는것이 아닌 시큐리티에서 '/auth/loginProc' 처리하도록 세팅
.loginProcessingUrl("/auth/loginProc") // 시큐리티가 가로챔 .defaultSuccessUrl("/"); //로그인 성공시 여기로
- 위에서 언급했듯이 시큐리티 세션에 유저정보를 넣어 관리하려면 User오브젝트로는 안된다. UserDetails용을 만들어야한다. (타입이 일치해야 들어간다)
@Data //스프링 시큐리티 고유 저장소에 'PrincipalDetail'이 저장 되는 것 public class PrincipalDetail implements UserDetails { private User user; //컴포지션 public PrincipalDetail(User user) { super(); this.user = user; } //계정 권한 목록 리턴(권한이 여러개면 포문) @Override public Collection<? extends GrantedAuthority> getAuthorities() { Collection<GrantedAuthority> collectors = new ArrayList<GrantedAuthority>(); collectors.add(()-> { return "ROLE_"+user.getRole(); }); return collectors; } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUsername(); } //계정 만료(true:만료 안됨) @Override public boolean isAccountNonExpired() { return true; } //계정 잠금(true:잠기지 않음) @Override public boolean isAccountNonLocked() { return true; } //비번 만료(true:만료 안됨) @Override public boolean isCredentialsNonExpired() { return true; } //계정 활성화(true: 활성화) @Override public boolean isEnabled() { return true; } }
- 시큐리티가 대신 로그인 처리시 해시화 암호와 DB암호의 유효성 검사 세팅
public class SecurityConfig extends WebSecurityConfigurerAdapter{ ... //해시화 암호 유효성 검사 @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(null).passwordEncoder(encodePwd()); } ...
- DB암호랑 비교시에 어떤 아이디의 암호인지 찾기위한 서비스 세팅 (반드시 만들어줘야 비교 가능하다)
@Service public class PrincipalDetailService implements UserDetailsService{ @Autowired private UserRepository userRepository; //스프링이 로그인처리를 대신할때 username/password 가로채는데 //password는 알아서 해주고, username만 시켜주면 됨 @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User principal = userRepository.findByUsername(username) .orElseThrow(()->{ return new UsernameNotFoundException("해당 유저가 업습니다.->"+username); }); return new PrincipalDetail(principal); //시큐리티에 유저 정보 저장 } }
- findByUsername 은 레퍼지토리에 없는 메소드이기때문에 직접 만들어 줘야 한다!!(안에 클릭해서 들어가보면 쓸 수 있는 메소드들을 볼 수 있다)
public interface UserRepository extends JpaRepository<User, Integer>{ //쿼리 네이밍 Optional<User> findByUsername(String username); }
-> 최종적으로 해시화 유효성 검사부분에 principalDetailService를 걸어줘야 해줘야 암호 비교를 함
public class SecurityConfig extends WebSecurityConfigurerAdapter{ @Autowired private PrincipalDetailService principalDetailService; .. //해시화 암호 유효성 검사 @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(principalDetailService).passwordEncoder(encodePwd()); }
※ 다시 시큐리티 최종 프로세스를 정리를 하자면
로그인 요청 -> SecurityConfig 에서 로그인 요청을 username/password와 함께 가로 챔 -> 가로챈 데이터를 PrincipalDetailService 의 loadUserByUsername()함수로 던짐 -> 이 함수에 걸려있는 아이디와 암호를 객체화 하여 리턴함 -> 리턴된 객체를 다시 SecurityConfig 해시화 하여 DB에서 비교를 함 -> 정상이면 세큐리티 세션에 저장됨
♣ 로그인 테스트
굿! 맞지않으면 에러를 띄우고 로그인이 되지 않는다 ☞ 내부에서 로그인이 됬는지 확인하는 방법
@Controller public class BoardController { @GetMapping({"","/"}) public String index(@AuthenticationPrincipal PrincipalDetail principal) { System.out.println("로그인한 사용자: "+principal.getUsername()); return "index"; } }
▲ 시큐리티 로그인이 완료 되었다. 이제부터 시큐리티 로그인 없이는 제한없는 주소는 제외하고 무조건 로그인 요청이 들어오게 된다.
'스프링 > 스프링부트+JPA - 블로그' 카테고리의 다른 글
스프링부트+JPA 블로그 프로젝트 08 스프링 작동 원리 복습 (0) 2021.09.01 스프링부트+JPA 블로그 프로젝트 07 게시판 (0) 2021.08.31 스프링부트+JPA 블로그 프로젝트 05 회원가입 개념(DTO/Ajax) (0) 2021.08.30 스프링부트+JPA 블로그 프로젝트 04 부트와 JPA의 필수 개념 (JSON/영속성/어노테이션 그리고 CRUD 테스트) (0) 2021.08.28 스프링부트+JPA 블로그 프로젝트 03 DB세팅, 모델링, 연관관계 (0) 2021.08.27