본문 바로가기

SpringBoot

[스프링 부트 3 백엔드 개발자 되기] ch 8. 스프링 시큐리티로 로그인/로그아웃, 회원가입 구현하기

1. 스프링 시큐리티

 

스프링 시큐리티(spring security)

- 스프링 기반의 애플리케이션 보안(인증,인가,권한)을 담당하는 스프링 하위 프레임워크 

- 스프링 기반 애플리케이션의 보안을 담당하는 스프링 하위 프레임워크 

- CSRF 공격, 세션 고정(session flxation) 공격을 방어해주고, 요청 헤더도 보안처리를 해주므로 개발자가 보안 관련 개발을 해야 하는 부담을 크게 줄여줌

 

인증과 인가 

 

인증(authentication)

- 사용자의 신원을 입증하는 과정

 

인가(authorization)

- 사이트의 특정 부분에 접근할 수 있는지 권한을 확인하는 작업 

 

 

필터 기반으로 동작하는 스프링 시큐리티 

 

- 스프링 시큐리티는 다양한 필터들로 나누어져 있으며, 각 필터에서 인증,인가와 관련된 작업을 처리한다. 

- SecurityContextPersistenceFilter부터 시작해서 아래로 내려가며 FilterSecurityInterceptor 까지 순서대로 필터를 거친다. 

- 필터를 실행할 때는 회색 화살표로 연결된 오른쪽 박스의 클래스를 거치며 실행한다. 

 

출처 : 스프링 부트 3 백엔드 개발자 되기 자바편 2판 신선영 지음 골든래빗

 

 

필터명 설명
보안 컨텍스트 지속성 필터 SecurityContextRepository에서 SecurityContext(접근 주체와 인증에 대한 정보를 담고 있는 객체)를 가져오거나 저장하는 역할
로그아웃필터 설정된 로그아웃 URL로 오는 요청을 확인해 해당 사용자를 로그아웃 처리
사용자 이름 비밀번호 인증 필터 인증 관리자. 폼 기반 로그인을 할 때 사용되는 필터로 아이디, 패스워드 데이터를 파싱해 인증 요청을 위임. 인증 성공하면 AuthenticationSuccessHandler를, 인증에 실패하면 AuthenticationFailureHandler를 실행
기본로그인페이지생성필터 사용자가 로그인 페이지를 따로 지정하지 않았을 때 기본으로 설정하는 로그인 페이지 관련 필터 
기본 인증 필터 요청 헤더에 있는 아이디와 패스워드를 파싱해서 인증 요청을 위임, 인증이 성공하면 AuthenticationSuccessHandler를, 인증에 실패하면 AuthenticationFailureHandler를 실행
요청캐시어웨어필터 로그인 성공  후 , 관련 있는 캐시 요청이 있는지 확인하고 캐시 요청을 처리. 예를 들어 로그인하지 않은 상태로 방문했던 페이지를 기억해두었다가 로그인 이후에 그 페이지로 이동시켜줌
보안 컨텍스트 홀더 인식 요청 필터 HttpServeltRequest 정보를 감싼다. 필터 체인 상의 다음 필터들에게 부가 정보를 제공하기 위해 사용
익명 인증 필터 필터가 호출되는 시점까지 인증되지 않았다면 익명 사용자 전용 객체인 AnonymousAuthentication을 만들어 SecurityContext에 넣어줌
세션 관리 필터 인증된 사용자와 관련된 세션 관련 작업을 실행. 세션 변조 방지 전략을 설정하고, 유효 하지 않은 세션에 대한 처리를 하고, 세션 생성 전략을 세우는 등의 작업을 처리
예외 번역 필터 요청을 처리하는 중에 발생할 수 있는 예외를 위임하거나 전달
필터보안인터셉터 접근 결정 관리자. AccessDecisionManager로 권한 부여 처리를 위임함으로써 접근 제어 결정을 쉽게 해준다. 이미 사용자가 인증되어 있으므로 유효한 사용자인지도 알 수 있다. 즉, 인가 관련 설정을 할 수 있다. 

 

 

 

1)

- 사용자가 폼에 아이디와 패스워드를 입력하면, HTTPServletRequest에 아이디와 비밀번호 정보가 전달된다. 

이때 AuthenticationFilter가 넘어온 아이디와 비밀번호의 유효성 검사를 한다. 

 

2)

- 유효성 검사가 끝나면 실제 구현체인 UsernamePasswordAuthenticationToken을 만들어 넘겨준다. 

 

삼)

- 전달받은 인증용 객체인 UsernamePasswordAuthenticationToken을 AuthenticationManager에게 보낸다. 

 

4)

- UsernamePasswordAuthenticationToken을 AuthenticationProvider에 보낸다. 

 

5) 

- 사용자 아이디를 UserDetailService에 보낸다. UserDetailService는 사용자 아이디로 찾은 사용자의 정보를 userDetails 객체로 만들어 AuthenticationProvider에게 전달한다. 

 

6)

- DB에 있는 사용자 정보를 가져온다. 

 

7)

- 입력 정보와 UserDetails의 정보를 비교해 실제 인증 처리를 한다. 

 

8)~10) 까지 인증이 완료되면 SecurityContextHolder에 Authentication을 저장한다. 인증 성공 여부에 따라 성공하면 AuthenticationSuccessHandler, 실패하면 AuthenticationFailureHandler 핸들러를 실행한다. 

 

 

2. 회원 도메인 만들기 

 

- 스프링 시큐리티를 사용해 인증, 인가 기능을 구현

- 회원 정보를 저장할 테이블을 만들고 테이블과 연결할 도메인을 만든 다음, 이 테이블과 연결할 회원 엔티티를 만든다. 

- 회원 엔티티와 연결되어 데이터를 조회하게 해줄 리포지터리를 만든 후, 마지막으로 스프링 시큐리티에서 사용자 정보를 가져오는 서비스 만들기 

 

1) 의존성 추가하기 

 

빌드.gradle

...
//스프링 시큐리티를 사용하기 위한 스타터 추가 
implementation 'org.springframework.boot:spring-boot-starter-security'
//타임리프에서 스프링 시큐리티를 사용하기 위한 의존성 추가
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
//스프링 시큐리티를 테스트하기 위한 의존성 추가
testImplementation 'org.springframework.security:spring-security-test'
...

 

 

2) 엔티티 만들기 

 

회원 엔티티 

컬럼명 자료형 null 허용 설명
id BIGINT N 기본키 일련번호, 기본키
email VARCHAR(255) N   이메일
password VARCHAR(255) N   패스워드(암호화하여 저장)
created_at DATETIME N   생성일자
updated_at DATETIME N   수정일자 

 

- domain 패키지에 User.java 파일을 생성하고 UserDetails 클래스를 상속하는 User 클래스 만들기 

 

User.java

@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class User implements UserDetails { //UserDetails를 상속받아 인증 객체로 사용

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", updatable = false)
    private Long id;

    @Column(name = "email", nullable = false, unique = true)
    private String email;

    @Column(name = "password", nullable = false)
    private String password;

    @Builder
    public User(String email, String password, String auth) {
        this.email = email;
        this.password = password;
    }


    @Override //권한 반환
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("user"));
    }

    //사용자의 id를 반환(고유한 값)
    @Override
    public String getUsername() {
        return email;
    }

    //사용자의 패스워드 반환
    @Override
    public String getPassword() {
        return password;
    }

    //계정 만료 여부 반환
    @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; //true->사용 가능
    }
}

 

- User 클래스가 상속한 UserDetails 클래스는 스프링 시큐리티에서 사용자의 인증 정보를 담아 두는 인터페이스이다. 

- 스프링 시큐리티에서 해당 객체를 통해 인증 정보를 가져오려는 필수 오버라이드 메서드 

 

메서드 반환 타입 설명
getAuthorities()  컬렉션<? extends GrantedAuthority>  사용자가 가지고 있는 권한의 목록을 반환한다. 현재 예제 코드에서는 사용자 이외의 권한이 없기 때문에 user 권한만 담아 반환
getUsername() 사용자를 식별할 수 있는 사용자 이름을 반환한다. 이때 사용되는 사용자 이름은 반드시 고유해야 한다. 현재 예제 코드는 유니크 속성이 적용된 이메일을 반환
getPassword() 사용자의 비밀번호를 반환한다. 이때 저장되어 있는 비밀번호는 암호화해서 저장
계정이 만료되지 않음() 부울 계정이 만료되었는지 확인하는 메서드, 만약 만료되지 않은 때는 true를 반환
계정 잠금 해제() 부울 계정이 잠금되었는지 확인하는 메서드,
만약 잠금되지 않은 때는 true를 반환 
자격 증명 만료 안 됨() 부울 비밀번호가 만료되었는지 확인하는 메서드, 만약 만료되지 않은 때는 true를 반환
사용 가능() 부울 계정이 사용 가능한지 확인하는 메서드, 만약 사용가능하다면 true를 반환 

 

 

리포지터리 만들기 

 

- User 엔티티에 대한 리포지터리 만들기 

 

사용자 저장소.java

public interface UserRepository extends JpaRepository<User,Long> {
    Optional<User> findByEmail(String email); //email로 사용자 정보를 가져옴 
}

 

- 이메일로 사용자를 식별할 수 있다. 따라서 사용자 정보를 가져오기 위해서는 스프링 시큐리티가 이메일을 전달받아야 한다. 스프링 데이터 JPA는 메서드 규칙에 맞춰 메서드를 선언하면 이름을 분석해 자동으로 쿼리를 생성해준다.

 

- findByEmail() 메서드는 실제 데이터베이스에 회원 정보를 요청할 때 다음 쿼리를 실행한다. 

사용자로부터
이메일 = #{이메일} 인 경우

 

 

서비스 메서드 코드 작성하기 

 

- 스프링 시큐리티에서 로그인을 진행할 때 사용자 정보를 가져오는 코드를 작성 

 

사용자 세부 서비스.java

- 스프링 시큐리티에서 사용자의 정보를 가져오는 UserDetailsService 인터페이스를 구현한다. 

@RequiredArgsConstructor 
@Service 
public  class  UserDetailService는  UserDetailsService를 구현합니다   .

    개인  최종 사용자 저장소 사용자 저장소;

    @Override 
    public User loadUserByUsername (문자열 이메일)  {
         return userRepository.findByEmail(이메일)
                .orElseThrow(() -> 새로운 IllegalArgumentException((이메일)));
    }
}

 

 

3. 시큐리티 설정하기 

 

config/WebSecurityConfig.java

- 실제 인증 처리를 하는 시큐리티 설정 파일 

@RequiredArgsConstructor 
@Configuration 
공개  클래스  WebSecurityConfig  {

    개인  최종 UserDetailService userService;

    //1. 스프링 시큐리티 기능 비활성화
    @Bean
    public WebSecurityCustomizer configure() {
        return (web) -> web.ignoring()
                .requestMatchers(toH2Console())
                .requestMatchers( 새로운 AntPathRequestMatcher( "/static/**" ));
    }

    //2.특정 HTTP 요청에 대한 웹 기반 보안 구성
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeRequests(auth -> auth //3.인증,인가 설정
                                .요청매처(
                                        새로운 AntPathRequestMatcher( "/login" ),
                                         새로운 AntPathRequestMatcher( "/signup" ),
                                         새로운 AntPathRequestMatcher( "/user" )
                                ).모두 허용()
                                .모든요청().인증됨())
                .formLogin(formLogin -> formLogin //4. 폼 기반 로그인 설정
                                .loginPage("/login")
                                .defaultSuccessUrl( "/기사" )
                         ).logout(logout -> logout //5.로그아웃 설정
                                .logoutSuccessUrl("/login")
                                .invalidateHttpSession( 참 )
                         )
                .csrf(AbstractHttpConfigurer::disable) //6.csrf 비활성화
                .짓다();
        }

        //7. 인증 관리자 관련 설정
        @Bean
        public AuthenticationManager authenticationManager(HttpSecurity http,
                                                           BCryptPasswordEncoder bCryptPasswordEncoder, UserDetailService userDetailService)
             가 예외를 throw합니다 .
            Dao 인증 제공자 인증 제공자 = 새로운 Dao 인증 제공자();
            authProvider.setUserDetailsService(userService); //8. 사용자 정보 서비스 설정
            authProvider.setPasswordEncoder(bCryptPasswordEncoder);
             새로운 ProviderManager(authProvider)를 반환합니다 .
        }
        
        //8. 패스워드 인코더로 사용할 빈 등록
        @Bean
        public BCryptPasswordEncoder bCryptPasswordEncoder() {
            return new BCryptPasswordEncoder();
        }


}

 

 

1)

- 스프링 시큐리티의 모든 기능을 사용하지 않게 설정하는 코드 

- 즉, 인증, 인가 서비스를 모든 곳에 적용하지는 않는다. 

- 일반적으로 정적 리소스(이미지,HTML 파일)에 설정한다. 

- 정적 리소스만 스프링 시큐리티 사용을 비활성화하는 데 static 하위 경로에 있는 리소스와 h2의 데이터를 확인하는데 사용하는 h2-console 하위 url을 대상으로 ignoring() 메서드를 사용한다. 

 

2)

- 특정 HTTP 요청에 대한 웹 기반 보안을 구성 

- 이 메서드에서 인증/인가 및 로그인, 로그아웃 관련 설정할 수 있다. 

 

삼)

- 특정 경로에 대한 엑세스 설정 

  • requestMatchers() : 특정 요청과 일치하는 url에 대한 엑세스를 설정
  • permitAll() : 누구나 접근이 가능하게 설정 
  • anyRequest() : 위에서 설정한 url 이외의 요청에 대해서 설정
  • authenticated() : 별도의 인가는 필요하지 않지만 성공된 상태여야 접근할 수 있다. 

4)

- 폼 기반 로그인 설정 

  • loginPage() : 로그인 페이지 경로를 설정
  • defaultSuccessUrl() : 로그인이 완료되었을 때 이동할 경로를 설정

5)

- 로그아웃 설정

  • logoutSuccessUrl() : 로그아웃이 완료되었을 때 이동할 경로를 설정
  • invalidateHttpSession() : 로그아웃 이후에 세션을 전체 삭제할지 여부를 설정 

6)

- CSRF 설정을 비활성화 

 

7)

- 인증 관리자 관련 설정

- 사용자 정보를 가져올 서비스를 재정의하거나, 인증 방법, 예를 들어 LDAP, JDBC 기반 인증 등을 설정할 때 사용 

 

8)

- 사용자 서비스를 설정

  • userDetailsService() : 사용자 정보를 가져올 서비스를 설정. 이때 설정하는 서비스 클래스는 반드시 UserDetailsService를 상속받은 클래스여야 한다. 
  • passwordEncoder() : 비밀번호를 암호화하기 위한 인코더를 설정 

 

4. 회원 가입 구현하기 

 

1) 서비스 메서드 코드 작성 

 

dto/AddUserRequest.java

@Getter
@Setter
public class AddUserRequest {
    private String email;
    private String password;
}

 

service/UserService.java

- AddUserRequest 객체를 인수로 받는 회원 정보 추가 메서드를 작성

@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public Long save(AddUserRequest dto) {
        return userRepository.save(User.builder()
                .email(dto.getEmail())
                //패스워드 암호화
                .password(bCryptPasswordEncoder.encode(dto.getPassword()))
                .build()).getId();
    }
}

 

 

2) 컨트롤러 작성

 

- 회원 가입 폼에서 회원 가입 요청을 받으면 서비스 메서드를 사용해 사용자를 저장한 뒤, 로그인 페이지로 이동하는 signup() 메서드를 작성 

 

UserApiController.java

- 회원 가입 처리가 된 다음 로그인 페이지로 이동하기 위해 redirect: 접두사를 붙였다. 이렇게 하면 회원 가입 처리가 끝나면 강제로 /login URL에 해당하는 화면으로 이동 

@RequiredArgsConstructor
@Controller
public class UserApiController {
    private final UserService userService;
    
    @PostMapping("/user")
    public String signup(AddUserRequest request) {
        userService.save(request); //회원 가입 메서드 호출
        return "redirect:/login"; //회원 가입이 완료된 이후에 로그인 페이지로 이동 
    }
}

 

 

5. 회원 가입, 로그인 뷰 작성하기 

 

1) 뷰 컨트롤러 구현

 

UserViewController.java

- /login 경로로 접근하면 login() 메서드가 login.html을, /signup 경로에 접근하면 signup() 메서드는 signup.html를 반환

@Controller
public class UserViewController {
    @GetMapping("/login")
    public String login() {
        return "login";
    }
    
    @GetMapping("/signup")
    public String signup() {
        return "signup";
    }
}

 

 

2) 뷰 작성

 

 

templates/login.html

 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>로그인</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">

  <style>
    .gradient-custom {
      background: linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1))
    }
  </style>
</head>
<body class="gradient-custom">
<section class="d-flex vh-100">
  <div class="container-fluid row justify-content-center align-content-center">
    <div class="card bg-dark" style="border-radius: 1rem;">
      <div class="card-body p-5 text-center">
        <h2 class="text-white">LOGIN</h2>
        <p class="text-white-50 mt-2 mb-5">서비스를 사용하려면 로그인을 해주세요!</p>

        <div class = "mb-2">
          <form action="/login" method="POST">
            <input type="hidden" th:name="${_csrf?.parameterName}" th:value="${_csrf?.token}" />
            <div class="mb-3">
              <label class="form-label text-white">Email address</label>
              <input type="email" class="form-control" name="username">
            </div>
            <div class="mb-3">
              <label class="form-label text-white">Password</label>
              <input type="password" class="form-control" name="password">
            </div>
            <button type="submit" class="btn btn-primary">Submit</button>
          </form>

          <button type="button" class="btn btn-secondary mt-3" onclick="location.href='/signup'">회원가입</button>
        </div>
      </div>
    </div>
  </div>
</section>
</body>
</html>

 

 

templates/signup.html 

 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>회원 가입</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">

  <style>
    .gradient-custom {
      background: linear-gradient(to right, rgba(254, 238, 229, 1), rgba(229, 193, 197, 1))
    }
  </style>
</head>
<body class="gradient-custom">
<section class="d-flex vh-100">
  <div class="container-fluid row justify-content-center align-content-center">
    <div class="card bg-dark" style="border-radius: 1rem;">
      <div class="card-body p-5 text-center">
        <h2 class="text-white">SIGN UP</h2>
        <p class="text-white-50 mt-2 mb-5">서비스 사용을 위한 회원 가입</p>

        <div class = "mb-2">
          <form th:action="@{/user}" method="POST">
            <!-- 토큰을 추가하여 CSRF 공격 방지 -->
            <input type="hidden" th:name="${_csrf?.parameterName}" th:value="${_csrf?.token}" />
            <div class="mb-3">
              <label class="form-label text-white">Email address</label>
              <input type="email" class="form-control" name="email">
            </div>
            <div class="mb-3">
              <label class="form-label text-white">Password</label>
              <input type="password" class="form-control" name="password">
            </div>

            <button type="submit" class="btn btn-primary">Submit</button>
          </form>
        </div>
      </div>
    </div>
  </div>
</section>
</body>
</html>

 

 

6. 로그아웃 구현하기 

 

1) 로그아웃 메서드 추가 

 

UserApiController.java

 

- logout() 메서드를 추가

- logout GET 요청을 하면 로그아웃을 담당하는 핸들러인 SecurityContextLogoutHandler의 logout() 메서드를 호출해서 로그아웃

...
 @GetMapping("/logout")
    public String logout(HttpServletRequest request, HttpServletResponse response) {
        new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication());
        
        return "redirect:/login";
    }

 

 

2) 로그아웃 뷰 추가 

 

articleList.html

- [로그아웃] 버튼을 추가 

...
<button type="button" class="btn btn-secondary" onclick="location.href='/logout'">로그아웃</button>
...

 

 

7. 실행 테스트하기 

 

1) 테스트를 위한 환경 변수 추가 

 

application.yml

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
    defer-datasource-initialization: true
  datasource:  # 1.데이터베이스 경로 추가 
    url: jdbc:h2:mem:testdb
    username: sa
  h2: # 2. H2 콘솔 활성화 
    console:
      enabled: true