Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

Technology-Share

thymeleaf+Spring Security를 이용한 로그인,회원가입 본문

Project

thymeleaf+Spring Security를 이용한 로그인,회원가입

chosunz4 2020. 10. 24. 12:43
  1. thymeleaf
  2. 적용하기
  3. 서버

  1. thymeleaf

    1. thymeleaf 란 ?!

      • 자바 라이브러리로써 웹과 웹이 아닌 환경 양쪽에서 텍스트, HTML, XML, Javascript,CSS 그리고 텍스트를 생성할 수 있는 템플릿 엔진이다.
    2. 특징

      1. 스프링 MVC와 통합 모듈을 제공하며 애플리케이션에서 JSP로 만든 기능들을 완전히 대체 할 수 있다.

      2. 스프링부트 쪽에서는 JSP, 그루비 등 다른 템플릿도 사용가능 하지만 타임리프가 가장 많이 쓰인다고 한다

      3. 쉬운 사용성

      4. 스프링 시큐리티 지원

      5. 스프링 웹플로우 지원 ( AJAX 이벤트 포함)

      6. 반복문, 조건문, 변수 정의 가능

      7. 예시

         <span th:text = "${data.text}"></span>
  2. 적용하기

    1. 의존성 추가

      • build.gradle 부분에 compile('org.springframework.boot:spring-boot-starter-thymeleaf') 를 추가하고
      • 스프링 시큐리티를 위해서 implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5' 도 추가한다.
    2. common 파일 설정

      • 항상 같은 뷰를 내보내는 상단 메뉴 부분을 묶어서 common.html로 선언 후 필수 코드를 삽입해둔다.

      • common.html

          <!DOCTYPE html>
          <html lang="en" xmlns:th="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
          <head th:fragment="head(title)">
          <body>
              <nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top" th:fragment="menu(menu)">
                  <a class="navbar-brand" href="#">Spring boot Service</a>
                  <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsExampleDefault"
                      aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
                      <span class="navbar-toggler-icon"></span>
                  </button>
        
                  <div class="collapse navbar-collapse" id="navbarsExampleDefault">
                      <ul class="navbar-nav mr-auto">
                          <li class="nav-item" th:classappend="${menu} == 'home'? 'active'">
                              <a class="nav-link" href="#" th:href="@{/}">홈 <span class="sr-only"
                                      th:if="${menu} == 'home'">(current)</span></a>
                          </li>
                          <li class="nav-item" th:classappend="${menu} == 'board'? 'active'">
                              <a class="nav-link" href="#" th:href="@{/board/list}">게시판 <span class="sr-only"
                                      th:if="${menu} == 'board'">(current)</span></a>
                          </li>
                      </ul>
                      <a class="btn btn-secondary mr-2 my-2 my-sm-0" th:href="@{/account/register}" sec:authorize="!isAuthenticated()">register</a>
                      <a class="btn btn-secondary my-2 my-sm-0" th:href="@{/account/login}" sec:authorize="!isAuthenticated()">login</a> <!-- 로그인이 안된 경우  -->
        
                      <form class="form-inline my-2 my-lg-0" th:action="@{/logout}" method="post" sec:authorize="isAuthenticated()">
                          <span class="text-white mr-2 mx-2" sec:authentication="name">사용자</span>
                          <span class="text-white mr-2 mx-2" sec:authentication="principal.authorities">권한</span>
                          <button class="btn btn-secondary my-2 my-sm-0" type="submit">logout</button>
                      </form>
                  </div>
              </nav>
          </body>
        
          </html>
      • xmlns:th="http://www.w3.org/1999/xhtml" : 타임리프 사용

      • xmlns:sec="http://www.thymeleaf.org/extras/spring-security" : 권한 인증을 위해 삽입한다.

      • th:fragment

        1. th:replace와 세트로 사용하는데 <head th:replace="fragments/common :: head('게시판')"> 의 형태로 *다른 html 파일의 상위 부분에 삽입되도록 해준다. *
        2. menu와 head 부분을 나누는데 사용하였다.
      • th:href="@{/}" : 해당 링크로 연결해준다.

      • th:classappend="${menu} == 'home'? 'active'">

        1. menu의 값이 home이라면 class active가 활성화되고
        2. 아니라면 class가 추가되지 않아 active가 표시되지 않는다.
        3. 현재 메뉴값을 확인하여 화면에 표시해준다.
      • sec:authentication="name"

        1. 시큐리티 로그인을 성공한 사용자의 name값을 가져온다.
      • sec:authentication="principal.authorities"

        1. 시큐리티 로그인한 사용자의 권한을 가져온다.
        2. 둘다 로그인하지 않았다면 사용자에게 보이지 않는다.
      • image

      • image

    3. login.html

       <body class="text-center">
       <form class="form-signin" th:action="@{/account/login}" method="post"> <!-- 요청 보내기-->
           <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
      
           <div th:if="${param.error}"  class="alert alert-danger" role="alert">
               Invalid username and password
           </div>
      
           <div th:if="${param.logout}"  class="alert alert-primary" role="alert">
               You have been logged out
           </div>
      
           <label for="username" class="sr-only">username</label>
           <input type="text" id="username" name="username" class="form-control" placeholder="username" required autofocus>
      
           <label for="password" class="sr-only">password</label>
           <input type="password" id="password" name="password" class="form-control" placeholder="password" required>
      
           <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
      
           <p class="mt-5 mb-3 text-muted">&copy; 2017-2020</p>
       </form>
       </body>
      • th:if="${param.error}"
        1. param.error의 값이 존재한다면 해당 코드가 삽입되고 아니라면 실행하지 않는다.
        2. 여기선 로그인 실패의 메시지를 담아서 화면에 보여주는 형태이다.
      • th:action="@{/account/login}"
        1. 흔히 알고 있는 action과 동일한 일을 수행한다.
        2. button의 submit으로 보내진다.
    4. register.html

       <body class="text-center">
       <form class="form-signin" th:action="@{/account/register}" method="post" th:object="${accountRequestDto}"> <!-- 요청 보내기-->
           <h1 class="h3 mb-3 font-weight-normal">회원가입</h1>
      
           <label for="username" class="sr-only">username</label>
           <input type="text" id="username" th:classappend="${#fields.hasErrors('username')} ? 'is-invalid'" name="username" class="form-control" required autofocus>
           <div class="invalid-feedback" th:if="${#fields.hasErrors('username')}" th:errors="*{username}">
               중복회원
           </div>
      
           <label for="password" class="sr-only">password</label>
           <input type="password" id="password" th:classappend="${#fields.hasErrors('password')} ? 'is-invalid'" name="password" class="form-control" placeholder="password" required>
           <div class="invalid-feedback" th:if="${#fields.hasErrors('password')}" th:errors="*{password}">
               비밀번호 불일치
           </div>
      
           <label for="password" class="sr-only">password_confirm</label>
           <input type="password" id="password_confirm" name="password_confirm" class="form-control" placeholder="password_confirm" required>
      
           <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
       </form>
      • th:object="${accountRequestDto}"
        1. 서버에 보낼때 accountRequestDto 라는 객체로 보내도록 지정한다
      • th:if="${#fields.hasErrors('username')}" th:errors="*{username}"
        1. username의 이름의 에러가 있는지 검사한다.
        2. 만약 있다면 해당 내용을 표시해준다.
        3. 검사는AccountValidator이 수행하고 그 값을 리턴해주는데 이는 뒤에서 설명
      • image image
  3. 서버

    1. Model

      • Account

          public class Account {
              @Id
              @GeneratedValue(strategy = GenerationType.IDENTITY)
              private long id;
        
              @Column(unique =true)
              private String username;
        
              @NotNull
              private String password;
        
              @ManyToMany
              @JoinTable(
                      name="account_role",
                      joinColumns = @JoinColumn(name="account_id"),
                      inverseJoinColumns = @JoinColumn(name="role_id"))
              private List<Role> roles = new ArrayList<>(); //null point 방지
        
              public Account(AccountRequestDto accountRequestDto){
                  this.username=accountRequestDto.getUsername();
                  this.password=accountRequestDto.getPassword();
              }
        
              @Builder
              public Account(String username, String password, List<Role> roles){
                  this.username=username;
                  this.password=password;
                  this.roles=roles;
              }
              public void encodePassword(PasswordEncoder passwordEncoder) {
                  this.password = passwordEncoder.encode(this.password);
              }
          }
        
        1. 권한과는 ManyToMany로 연결했다
        2. 자신의 가지고 있는 password를 암호화 가능
      • Role

          @Entity
          @Setter
          @Getter
          public class Role {
        
              @Id
              @GeneratedValue(strategy = GenerationType.IDENTITY)
              private Long id;
        
              private String name;
        
              @ManyToMany(mappedBy = "roles")
              private List<Account> accounts;
          }
      • AccountRequestDto

          package com.predict.stock.Dto.account;
        
          import com.predict.stock.account.Account;
          import lombok.Builder;
          import lombok.Getter;
          import lombok.NoArgsConstructor;
          import lombok.Setter;
        
          @Getter
          @Setter
          @NoArgsConstructor
          public class AccountRequestDto {
        
              private String username;
        
              private String password;
        
              private String password_confirm;
        
              @Builder
              public AccountRequestDto(String username, String password, String password_confirm){
                  this.username = username;
                  this.password = password;
                  this.password_confirm = password_confirm;
              }
          }
        • 회원가입 요청이 오면 쓰이는 객체이다.
        • @Entity로 테이블에 바로 매핑되는 객체가 아닌 Request 전용 모델을 선언 후 여러 작업을 거친 다음 account로 저장한다.
        • 이름과 비밀번호, 비밀번호 확인이 존재하며
        • 해당 객체를 검사한 뒤 문제가 없으면 Account 객체로 전환하여 저장한다.
    2. Validator

       @Component
       public class AccountValidator implements Validator {
      
           @Autowired
           private AccountRepository accountRepository;
      
           @Override
           public boolean supports(Class<?> clazz) {
               return AccountRequestDto.class.equals(clazz);
           }
      
           @Override
           public void validate(Object obj, Errors errors) {
               AccountRequestDto accountRequestDto = (AccountRequestDto) obj;
               if(!((AccountRequestDto) obj).getPassword().equals(((AccountRequestDto) obj).getPassword_confirm())){
                   //비밀번호와 비밀번호 확인이 다르다면
                   errors.rejectValue("password", "key","비밀번호가 일치하지 않습니다.");
               } else if(accountRepository.findByUsername(((AccountRequestDto) obj).getUsername()) !=null){
                   // 이름이 존재하면
                   errors.rejectValue("username", "key","이미 사용자 이름이 존재합니다.");
               }
      
           }// 비밀번호 검사할때 쓰면 될듯
       }
      
      • validate@Override해서 넘어온 객체를 검사한다.
      • 조건은 동일한 이름이 존재하는지와 password, password_confirm의 동일 여부이다.
    3. Controller

       @Controller
       @RequiredArgsConstructor
       @RequestMapping("/account")
       public class AccountController {
      
           private final AccountService accountService;
           private final AccountValidator accountValidator;
      
           @GetMapping("/login")
           public String login(){
               return "/account/login";
           }
      
           @GetMapping("/register")
           public String register(Model model){
               model.addAttribute("accountRequestDto",new AccountRequestDto());
               return "/account/register";
           }
      
           @PostMapping("/register")
           public String register(@Valid AccountRequestDto accountRequestDto, BindingResult bindingResult){
               accountValidator.validate(accountRequestDto, bindingResult);
               System.out.println(bindingResult.hasErrors());
               if(bindingResult.hasErrors()) {
                   return "/account/register"; // 실패
               }
               else {
                   // 성공
                   accountService.save(accountRequestDto);
                   return "redirect:/";
               }
           }
      
       }
      
      • @GetMapping("/register")
        1. 빈 객체를 보내는 이유는 위쪽 register.html에서 에러검사를 위해 th:object="${accountRequestDto}"로 객체를 받는 문장이 존재하기 때문에 안쓰면 오류를 발생시킨다.
        2. 따라서 빈 객체 하나를 생성하여 넘겨준다.
      • @PostMapping("/register")
        1. @Valid로 requestBody로 들어온 객체의 검증
        2. BindResult로 에러가 있다면 해당 에러 메시지를 (key, value) 형태로 전달 후 다시 /account/register를 호출한다.
    4. Service

      • AccountService

          @Service
          @RequiredArgsConstructor
          public class AccountService implements UserDetailsService {
        
        
            private final AccountRepository accountRepository;
            private final RoleRepository roleRepository;
            private final PasswordEncoder passwordEncoder;

            @Override
            public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
                Account account = accountRepository.findByUsername(username);

                List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
                grantedAuthorities.add(new SimpleGrantedAuthority("role_user"));
                System.out.println(new User(account.getUsername(),account.getPassword(),grantedAuthorities));
                return new User(account.getUsername(),account.getPassword(),grantedAuthorities);

                // UserDetails를 구현한 객체인 User를 리턴하면된다.

            }

            public Account save(AccountRequestDto accountRequestDto){

                    //입력한 비밀번호와 비밀번호 확인이 같다면
                    Role role = roleRepository.findByName("role_user");
                    // 일반 유저 권한을 찾아서 가져온다음.
                    Account account = new Account(accountRequestDto);
                    // 새로 account를 하나 만들어서
                    account.encodePassword(passwordEncoder);
                    account.getRoles().add(role);
                    return accountRepository.save(account);
            }
            // 요청이 들어오면  account가 들어오면 account의 비밀번호를 인코딩 한 뒤에 저장한다.
            // DTO로 바꾸려면 들어오는 변수가 Dto로 바뀌고 Dto의 비밀번호를 인코딩 한 뒤 account에 전달하면 된다.

        }

        ```

5. **Spring Security에서 로그인**

    * **``UserDetailsService`` 인터페이스를 구현하면 ``loadUserByUsername`` 메소드가 오버라이딩 된다.**
    * DB에서 사용자 정보를 저장하는 역할을 수행한다.
    * **인증 절차는 미리 ``Validator``를 통해 수행하므로 그냥 바로 저장!**
    *