개발/JAVA

[자바웹개발워크북] 8. 스프링시큐리티

독코더 2023. 2. 13. 21:59
반응형

/*

 *

 * 자바웹개발워크북의 내용을 정리하기 위한 포스팅입니다.

 */

 

1. 스프링 시큐리티 적용하기

사용자의 로그링과 세션트래킹은 웹애플리케이션에서 필수적인 기능입니다.

과거에는 HttpSession과 Cookie를 이용해서 처리했지만 스프링에선 스프링시큐리티와 약간의 설정을 통해 구현할 수 있습니다.

스프링 시큐리티를 이용하면 개발자는 약간의 코드와 설정만으로 로그인 처리와 자동 로그인, 로그인 후에 페이지 이동 등을 처리할 수 있기때문에 개발의 생상성을 높일수 있습니다. HttpSession이나 Cookie등에 대해서도 자동으로 처리하는 부분이 많기때문에 직접 이들을 다루는 일 또한 줄일수 있습니다.

스프링 시큐리티 기본설정

프로젝트 내 build.gradle파일의 dependencies에 스프링 시큐리티 관련 라이브러리를 추가합니다.

dependencies {
    ... 생략 ...
    // Spring Security
    implementation 'org.springframework.boot:spring-boot-starter-security'
}

 

스프링 시큐리티 관련 설정 추가

스프링 시큐리티의 경우 단순히 application.properties를 이용하는 설정보다 코드를 이용해서 설정을 조정하는 경우가 더 많기때문에 별도의 클래스를 이용해서 설정을 조정합니다.

기존의 config패키지에 CustomSecutiryConfig클래스를 추가합니다.

@Log4j2
@Configuration
@RequiredArgsConstructor
public class CustomSecurityConfig {
}

작성한 클래스는 @Configuration어노테이션을 적용합니다. 설정 완료 후 프로젝트를 실행하면 알수없는 password가 생성되서 출력되는것을 확인할수 있습니다. (값은 매번 다르게 생성됩니다. 반면 사용자 아이디는 기본적으로 'user'입니다.)

기존에 문제없이 접근할수있던 '/board/list'에 로그인처리가 필요해졌습니다. 스프링 시큐리티는 별도의 설정이 없을땐 모든 자원에 필요한 권한이나 로그인여부 등을 확인합니다.

CustomSecurityConfig에 SecurityFilterChain이라는 객체를 반환하는 메소드를 작성합니다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    log.info("--------------- config ---------------");
    return http.build();
}

다시 프로젝트를 실행하면 filterChain()가 동작하면서 이전과 달리 '/board/list'에 바로 접근할수 있습니다.

이제 filterChain()의 내부 코드를 이용해 최소한의 설정으로 필요한 자원의 접근을 제어할것입니다.

 

로그 레벨 조정

스프링 시큐리티의 동작은 웹에서 사용하는 필터를 통해서 동작하고 많은 필터들이 단계별로 동작하게 됩니다. 따라서 문제가 발생하면 어떤 필터에서 어떤 문제가 생겼는지 알수있도록 application.properties의 로그설정을 최대한 낮게 설정해서 관련된 에러를 볼수있도록 설정하는것이 좋습니다. application.properties의 로그레벨을 다음과 같이 수정합니다.

logging.level.org.springframework=info
logging.level.org.zerock=debug
logging.level.org.springframework.security=trace

다시 실행해보면 하나의 자원을 호출할때마다 약 10개이상의 필터들이 동작하는것을 확인할수 있습니다.

로그를 보면 단순한 css파일이나 js파일등에도 필터가 적용되고 있습니다.

정적인 파일들에는 굳이 시큐리티를 적용할 필요가 없으므로 CustomSecurityConfig에 webSecurityCustomizer()를 추가합니다.

@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
    log.info("--------------- web config ---------------");
    return (web) -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}

이렇게 설정하면 정적자원들은 스프링 시큐리티 적용에서 제외시킬수 있습니다.

 

인증과 인가/권한(가장 중요한 개념)

▶ 인증(Authentication): '스스로를 증명하다'라는 뜻으로 로그인 개념입니다. 인증을 위해서 자신의 정보를 제공합니다.(ID와 PW)

▶ 인가(Authorization): '허가나 권한'이라는 개념입니다. 인증 완료라고해도 이에 접근권한이 있는지를 확인하는 과정을 의미합니다.

웹애플리케이션에서 스프링시큐리티를 적용하면 로그인을 통해서 '인증'을 수행하고 컨트롤러의 경로에 시큐리티설정으로 특정한 권한이 있는 사용자들만 접근할수 있도록 설정하게 됩니다.

 

스프링시큐리티의 로그인(인증단계)은 과거의 웹과 다르게 다음과 같이 동작하는 부분이 있으므로 주의해야합니다.

▶ 사용자의 ID(username)만으로 사용자의 정보를 로딩

▶ 로딩된 사용자의 정보를 이용해서 패스워드를 검증

스프링시큐리티의 동작방식은 웹에서 로그인처리로 ID와 PW를 한번에 조회하는 방식과 달리 ID(username)만으로 사용자정보를 로딩하고 나중에 PW를 검증하는 방식입니다.

인증처리는 '인증 제공자(Authentication Provider)'라는 존재를 이용해서 처리되는데 인증제공자와 그 이하의 흐름은 일반적으로 커스터마이징해야하는 경우가 거의 없으므로 실제 인증처리를 담당하는 객체만을 커스터마이징하는 경우가 대부분입니다.

 

인증 처리를 위한 UserDetailsService

스프링시큐리티에서 가장 중요한 객체는 실제로 인증을 처리하는 UserDetailsService라는 인터페이스의 구현체입니다.

UserDetailsService인터페이스는 loadUserByUsername()이라는 하나의 메소드를 가지는데 이것이 실제인증을 처리할때 호출됩니다.

실제 개발 작업은 UserDetailsService를 구현해서 username이라고 부르는 사용자의 ID인증을 코드로 구현하는것입니다.

config패키지의 CustomSecurityConfig에는 로그인화면에서 로그인을 진행한다는 설정을 다음과 같이 추가합니다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    log.info("--------------- config ---------------");
    http.formLogin();
    return http.build();
}

프로젝트에 security패키지를 구성하고 CustomUserDetailsService클래스를 추가합니다.

@Log4j2
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("loadUserByUsername : " + username);
        return null;
    }
}

프로젝트를 실행하고 '/login'화면에서 로그인을 시도하면 로그인처리가 되진 않지만 해당 메소드가 실행되는것을 확인할수 있습니다.

 

UserDetails라는 반환타입

loadUserByUsername()의 반환타입은 org.springframework.security.core.userdetails패키지의 UserDetails라는 인터페이스 타입으로 지정되어있습니다. UserDetails는 사용자인증(Authentication)과 관련된 정보들을 저장하는 역할을 합니다.

스프링시큐리티는 내부적으로 UserDetails타입의 객체를 이용해서 PW를 검사하고, 사용자권한을 확인하는 방식으로 동작합니다.

UserDetails인터페이스의 추상메소드들은 다음과 같습니다.

메소드명 설명 리턴타입
getAuthorities() 계정이 갖고있는 권한 목록을 리턴한다. Collection<? extends GrantedAurhority>
getPassword() 계정의 비밀번호를 리턴한다. String
getUsername() 계정의 이름을 리턴한다. String
isAccountNonExpired() 계정이 만료되지 않았는지 리턴한다.(true:만료안됨) boolean
isAccountNonLocked() 계정이 잠겨있지 않았는지 리턴한다.(true:잠기지않음) boolean
isCredentialNonExpired() 비밀번호가 만료되지 않았는지 리턴한다.(true:만료안됨) boolean
isEnabled() 계정이 활성화(사용가능)인지 리턴한다.(true:활성화) boolean

이중에서 getAuthorities()메소드는 사용자가 가진 모든 인가(Authority)정보를 반환해야합니다.

정리해보면, 개발단계에서 UserDetails인터페이스타입에 맞는 객체가 필요하고 이를 CustomUserDetailsService에서 반환해야합니다.

 

스프링시큐리티의 API에는 UserDetails인터페이스를 구현한 User클래스를 제공하므로 이를 임시로 만들어서 로그인처리를 하겠습니다.

User클래스는 빌더방식을 지원하므로 loadUserByUsername()에 약간의 코드를 추가해줍니다.

@Log4j2
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("loadUserByUsername : " + username);
        
        UserDetails userDetails = User.builder()
                .username("user1")
                .password("1111")
                .authorities("ROLE_USER")
                .build();
        
        return userDetails;
    }
}

CustomUserDetailsService가 반영된 상태에서 '/login'을 수행하면 문제가 발생합니다.

실행 자체는 되지만 다음과 같이 PasswordEncoder가 없어서 다음과 같은 에러가 발생합니다.

스프링시큐리티는 기본적으로 PasswordEncoder라는 존재를 필요로합니다. PasswordEncoder역시 인터페이스로 제공하는데,

이를 구현하거나 스프링시큐리티 API에서 제공하는 클래스를 지정할수 있습니다.

여러 PasswordEncoder타입의 클래스중 대표적인것은 BCryptPasswordEncoder라는 클래스입니다.

이는 해시알고리즘으로 암호화 처리되는데 같은 문자열이라고해도 매번 해시 처리된 결과가 다르므로 패스워드 암호화에 많이 사용됩니다.

다음과 같이 config패키지의 CustomSecurityConfig에 PasswordEncoder설정을 추가합니다.

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

CustomUserDetailsService에서는 PasswordEncoder를 잠깐 테스트하는 용도로 사용할 것이므로 코드를 통해서 BCryptPasswordEncoder를 생성해 임시로 동작하도록 설계해보겠습니다.

@Log4j2
@Service
public class CustomUserDetailsService implements UserDetailsService {

    private PasswordEncoder passwordEncoder;

    public CustomUserDetailsService() {
        this.passwordEncoder = new BCryptPasswordEncoder();
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("loadUserByUsername : " + username);

        UserDetails userDetails = User.builder()
                .username("user1")
                //.password("1111")
                .password(passwordEncoder.encode("1111")) // 패스워드 인코딩 필
                .authorities("ROLE_USER")
                .build();

        return userDetails;
    }
}

프로젝트를 재실행하고 '/login'에서 'user1 / 1111' 값으로 로그인을 진행하면, 컨트롤러의 '/'에 대한 페이지가 없으므로 에러가 나긴하지만

서버에는 아무 문제가 없이 로그인이 된 것을 확인할수 있습니다.

 

어노테이션을 이용한 권한 체크

특정한 경로에 시큐리티를 적용해 보겠습니다. 예를들어 글목록은 누구나 볼수있지만, 글쓰기는 로그인한 사용자만 사용할수있어야합니다.

이처럼 권한설정은 코드로 설정할수있고, 어노테이션으로 설정할수도 있습니다. 여기선 조금더 편한 어노테이션을 이용해보겠습니다.

어노테이션으로 권한을 설정하려면 설정관련 클래스에 @EnableGlobalMethodSecurity어노테이션을 추가해야 합니다.

config패키지의 CustomSecurityConfig 위쪽에 @EnableGlobalMethodSecurity를 다음과 같이 추가합니다.

@Log4j2
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class CustomSecurityConfig {
...
}

prePostEnabled속성은 원하는곳에 @PreAuthorize 혹은 @PostAuthorize을 이용해서 사전 혹은 사후의 권한을 체크할수 있습니다.

'board/register'경로에 'USER'권한을 체크할수 있도록 BoardController의 registerGET()을 수정해보겠습니다.

@PreAuthorize("hasRole('USER')")
@GetMapping("/register")
public void registerGet(){
    
}

'board/register'를 호출하면 @PreAuthorize에 막혀서 로그인페이지로 이동하게 됩니다. (로그인상태라면 바로 register화면으로 이동)

'user1 / 1111'로 로그인을 시도하면 CustomUserDetailsService에서 'ROLE_USER'라는 인가를 가지도록 코드가 작성되어 있으므로,

해당 사용자는 @PreAuthorize에서 'hasRole('USER')'라는 값이 true가 되어 접근할수 있게됩니다.

 

이러한 권한설정에서 @PreAuthorize / @PostAuthorize의 ()안에 들어가는 문자열은 다음과 같습니다.

표현식(메소드) 설명
authenticated() 인증된 사용자들만 허용
permitAll() 모두 허용
anonymous() 익명의 사용자 허용
hasRole(표현식) 특정한 권한이 있는 사용자 허용
hasAnyRole(표현식) 여러 권한 중 하나만 존재해도 허용

 

커스텀 로그인 페이지

스프링시큐리티는 별도의 페이지를 생성하지 않아도 자동으로 로그인페이지를 제공하지만 디자인을 바꾸고싶다면 별도의 로그인페이지를 만들어서 사용하는것이 더 일반적입니다. 이에대한 설정은 CustomSecurityConfig를 수정해서 처리합니다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    log.info("--------------- config ---------------");
    http.formLogin().loginPage("/member/login");
    return http.build();
}

controller패키지에 MemberController클래스를 추가합니다.

@Controller
@RequestMapping("/member")
@Log4j2
@RequiredArgsConstructor
public class MemberController {
    
    @GetMapping("/login")
    public void loginGET(String error, String logout) {
        log.info("login get.......");
        log.info("logout : " + logout);
    }
}

templete폴더에는 member폴더를 추가하고 login.html을 추가합니다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
    <meta name="description" content="" />
    <meta name="author" content="" />
    <title>Simple sidebar - Login</title>
    <!-- Favicon -->
    <link rel="icon" type="image/x-icon" th:href="@{/assests/favicon.ico}" />
    <!-- Core theme CSS (includes Bootstrap) -->
    <link th:href="@{/css/styles.css}" rel="stylesheet" />
</head>
<body class="align-middle">
    <div class="container-fluid d-flex justify-content-center" style="height: 100vh">
        <div class="card-header">
            LOGIN Page
        </div>
        <div class="card-body">
            <form id="registerForm" action="/member/login" method="post">
                <div class="input-group mb-3">
                    <span class="input-group-text">아이디</span>
                    <input type="text" name="username" class="form-control" placeholder="USER ID">
                </div>

                <div class="input-group mb-3">
                    <span class="input-group-text">패스워드</span>
                    <input type="text" name="password" class="form-control" placeholder="PASSWORD">
                </div>

                <div class="my-4">
                    <div class="float-end">
                        <button type="submit" class="btn btn-primary submitBtn">로그인</button>
                    </div>
                </div>
            </form>
        </div><!--end Cardbody-->
    </div><!--end Card-->
</body>
</html>

이제 로그인을 시도하면 403(Forbidden)에러가 발생하는데 스프링시큐리티는 기본적으로 GET방식을 제외한 POST/PUT/DELETE 요청에 CSRF토큰을 요구하기 때문입니다.

CSRF란, 'Cross-Site Request Forgery(크로스사이트 간 요청위조)'의 약어로 권한이 있는 사용자가 자신도 모르게 요청을 전송하게하는 공격방식입니다. CSRF토큰은 사용자가 사이트를 이용할때 매번 변경되는 문자열을 생성하고 이를 요청시에 검증하는 방식입니다.

커스텀로그인으로 전환하기전엔 '/login'화면내부에 '_csrf'라는 이름으로 CSRF토큰을 사용하고 있었습니다.

CSRF토큰을 이용하는것이 보안상 안전하지만 기존의 POST/PUT/DELETE를 이용하는 모든 코드를 수정해야하는 단점이 있습니다.

특히 Ajax로 POST방식을 이용하는 경우에도 추가적인 작업이 필요하기때문에 CSRF토큰을 비활성화하는 방식을 이용하겠습니다.

 

CustomSecurityConfig클래스에 다음의 코드를 추가합니다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    log.info("--------------- config ---------------");
    http.formLogin().loginPage("/member/login");
    http.csrf().disable();
    return http.build();
}

CSRF토큰을 비활성화하면 username과 password만으로 로그인이 가능해집니다.

중요한점은 POST방식에 대한 코드를 작성하지 않았다는 점입니다. 로그인 경로를 http.formLogin().loginPage("/member/login")로

지정하면 POST방식 처리 역시 같은 경로로 스프링시큐리티 내부에서 처리되기 때문입니다.

로그인작업과 마찬가지로 POST방식으로 처리되는 로그아웃 역시 GET방식으로 동작하는 로그아웃화면을 구성하기만 하면 됩니다.

 

로그아웃 처리 설정

스프링시큐리티는 기본적으로 HttpSession을 이용해서 처리되기때문에 로그아웃은 세션을 유지하는 사용하는 쿠키를 삭제하면 자동으로 로그아웃됩니다. 스프링시큐리티에선 기본적으로 '/logout'이라는 경로를 제공하는데 CSRF토큰이 비활성화 된 경우엔 GET방식만으로도 로그아웃이 가능합니다. MemberController의 loginGET()은 '?logout'을 이용해서 사용자의 로그아웃 여부를 확인할수 있습니다.

@GetMapping("/login")
public void loginGET(String error, String logout) {
    log.info("login get.......");
    log.info("logout : " + logout);

    if (logout != null) {
        log.info("user logout....");
    }
}
<div class="card-body">
    <th:block th:if="${param.logout != null}">
        <h1>Logout......</h1>
    </th:block>
    <form id="registerForm" action="/member/login" method="post" th:if="${param.logout == null}">
        ... 생략 ...
    </form>
</div><!--end Cardbody-->

 

remember-me 기능 설정

스프링시큐리티의 'remember-me'기능은 쿠키를 이용해서 브라우저에 로그인했던 정보를 유지하기때문에 매번 로그인을 실행할 필요가 없어집니다. 모바일환경에서는 로그인자체가 불편하기때문에 최근 유행하는 서버스에는 'remember-me'라는 기능을 많이 사용합니다.

기존 HttpSession을 이용했던것과 달리 remember-me는 쿠키에 유효기간을 지정해서 브라우저가 보관하게하고 쿠키의 값인 특정한 문자열을 보관시켜서 로그인 관련 정보를 유지하는 방식입니다.

테이블생성

remember-me의 쿠키값을 생성할때 필요한 정보들을 보관하는 기본 방법은 데이터베이스를 이용하는것입니다.

현재 프로젝트에서 사용하는 데이터베이스에 persitent_logins라는 테이블을 생성해 둡니다.

이 테이블의 이름은 스프링시큐리티 내부에서 사용하기 때문에 변경하지 않도록 주의합니다.

create table persistent_logins (
    username varchar(64) not null,
    series varchar(64) primary key,
    token varchar(64) not null,
    last_used timestamp not null
);

자동로그인을 위한 설정변경

remember-me기능의 설정은 쿠키를 발행하도록 CustomSecurityConfig의 내용을 수정해서 처리할수 있습니다.

이때 쿠키와 관련된 정보를 테이블로 보관하도록 지정하는데 DataSource가 필요하고 UserDetailsService타입의 객체가 필요합니다.

@Log4j2
@Configuration
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class CustomSecurityConfig {
    
    // 주입필요
    private final DataSource dataSource;
    private final CustomUserDetailsService userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        log.info("--------------- config ---------------");
        
        // 커스텀 로그인 페이지
        http.formLogin().loginPage("/member/login");
        // CSRF토큰 비활성화
        http.csrf().disable();
        
        http.rememberMe()
                .key("12345678")
                .tokenRepository(persistentTokenRepository())
                .userDetailsService(userDetailsService)
                .tokenValiditySeconds(60*60*24*30);
        
        return http.build();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        log.info("--------------- web config ---------------");
        return (web) -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }
    
    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
        repo.setDataSource(dataSource);
        return repo;
    }

remember-me쿠키를 생성할때는 쿠키의 값을 인코딩하기위한 키값과 필요한 정보를 저장하는 tokenRepository를 지정합니다.

 

자동 로그인을 위한 화면설정

remember-me를 활성화하기 위해서는 로그인화면에서 'remember-me'라는 이름의 값이 같이 전달되어야 합니다.

따라서 기존의 login.html파일에 패스워드 밑에 체크박스를 추가합니다.

<div class="input-group mb-3">
    <span class="input-group-text">패스워드</span>
    <input type="text" name="password" class="form-control" placeholder="PASSWORD">
</div>

<div class="input-group mb-3">
    <input class="form-check-input" type="checkbox" name="remember-me">
    <label class="form-check-label">자동로그인</label>
</div>

위의 설정이 반영된 후 사용자가 로그인하면 'remember-me'라는 이름의 쿠키가 30일의 유효기간을 가지고 생성된것을 볼수 있습니다.

데이터베이스에 persistent_logins를 조회하면 user1의 'remember-me'쿠키가 유지되고 있음을 볼수 있습니다.

로그아웃의 경우 기본설정은 'remember-me'쿠키의 삭제입니다. 별도의 처리를 하지 않아도 다음과 같이 'Set-cookie'헤더에 remember-me쿠기의 value가 없고 Expires값이 '1970-01-01'로 지정되는것을 볼수있습니다.

데이터베이스 역시 로그아웃이 수행되면 persistent_logins테이블에 username을 기준으로 데이터가 삭제됩니다.

 

화면에서 인증 처리하기와 컨트롤러

인증 처리된 사용자의 세밀한 처리는 컨트롤러와 화면에서도 다음과 같이 처리되어야 합니다.

▶ 화면상에서 로그인 혹은 특정 권한별 제어

▶ 컨트롤러상에서 인증된 정보를 활용하는 경우

 

예를들어, 화면에서 게시물의 작성자한테만 '수정/삭제'로 이동할 수 있는 버튼이 보이거나

게시물 작성시 현재 로그인한 사용자의 아이디 등을 미리 세팅하는 작업이 필요할수도 있고,

컨트롤러에는 현재 로그인한 정보와 게시물 작성자가 같은 사용자일때만 삭제 처리하는 등의 작업이 필요합니다.

 

Thymeleaf에서 인증 정보 활용

Thymeleaf에서 인증 정보를 처리하기 위해서는 Thymeleaf에서 스프링시큐리티를 사용하기위한 라이브러리를 이용해야 합니다.

build.gradle에 Thymeleaf Extras Springsecurity5 라이브러리를 추가합니다.

implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'

라이브러리를 추가한 후 board폴더의 register.html의 <script>에 다음과 같이 코드를 추가합니다.

<script layout:fragment="script" th:inline="javascript">

    const auth =  [[${#authentication}]]

만일 자바스크립트로 현재 사용자 정보를 이용해야한다면 ${#authentication.principal}을 이용해서 필요한 정보를 활용합니다.

 

게시물 등록 작업

게시물 등록에서는 게시물 작성자 부분을 현재 로그인한 사용자 아이디로 처리해야 합니다.

register.html 코드 위에 추가된 Thymeleaf Extras Springsecurity5의 네임스페이스를 추가합니다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/basic.html}">

작성자부분에는 현재 사용자 아이디를 출력하고 읽기전용으로 처리합니다.

<div class="input-group mb-3">
    <span class="input-group-text">작성자</span>
    <input type="text" name="writer" class="form-control" placeholder="writer"
            th:value="${#authentication.principal.username}" readonly>
</div>

 

게시물 조회 작업

게시물 조회는 BoardController에서 로그인한 사용자만 조회할수 있도록 수정해야합니다.

@PreAuthorize와 'isAuthenticated()' 표현식으로 로그인한 사용자만으로 제한해보겠습니다.

@PreAuthorize("isAuthenticated()")
@GetMapping({"/read", "/modify"})
public void read(long bno, PageRequestDTO pageRequestDTO, Model model) {

    BoardDTO boardDTO = boardService.readOne(bno);

    model.addAttribute("dto", boardDTO);
}

게시물 조회를 출력하는 read.html에는 다음과 같은 작업이 필요합니다.

▶ 현재 로그인된 사용자와 게시물작성자가 같은 경우에만 화면 아래쪽에 [수정]버튼이 활성화 되어야 합니다.

▶ 댓글 관련된 작업에도 현재 사용자 정보를 활용해야 합니다.

read.html 코드위 네임스페이스를 추가합니다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5"
      layout:decorate="~{layout/basic.html}">

본인의 글일 경우에만 [수정]버튼을 활성화 시켜줍니다.

<div class="my-4" th:with="user=${#authentication.principal}">
    <div class="float-end" th:with="link = ${pageRequestDTO.getLink()}">
        <a th:href="|@{/board/list}?${link}|" class="text-decoration-none">
            <button type="button" class="btn btn-primary">목록</button>
        </a>
        <a th:if="${user != null && user.username == dto.writer}" th:href="|@{/board/modify(bno=${dto.bno})}&${link}|" class="text-decoration-none">
            <button type="button" class="btn btn-secondary">수정</button>
        </a>
    </div>
</div>

댓글을 추가할때는 댓글의 작성자가 현재 로그인한 사용자가 되도록 고정해야 합니다. 

<div class="modal-body">
    <div class="input-group mb-3">
        <span class="input-group-text">댓글 내용</span>
        <input type="text" class="form-control replyText">
    </div>
    <div class="input-group mb-3" th:with="user=${#authentication.principal}">
        <span class="input-group-text">작성자</span>
        <input type="text" class="form-control replyer" th:value="${user.username}" readonly>
    </div>
    <div class="modal-footer">
        <button type="button" class="btn btn-primary registerBtn">등록</button>
        <button type="button" class="btn btn-outline-dark closeRegisterBtn">닫기</button>
    </div>
</div>

댓글의 수정과 삭제는 자바스크립트를 이용해서 모달창을 보여주게 됩니다. 이때 로그인한 사용자의 정보를 활용해서 자신이 작성한 댓글만 수정/삭제가 가능하도록 조정합니다. 자바스크립트에 현재 로그인한 사용자의 아이디를 변수로 지정합니다.

<script layout:fragment="script" th:inline="javascript">
    
    const currentUser = [[${#authentication.principal.username}]]

댓글의 수정/삭제는 Ajax로 댓글을 가져온 후에 이루어지므로 이때 댓글의 작성자와 currentUser가 일치하는지를 확인합니다.

let hasAuth = false // 댓글의 작성자와 currentUser의 일치여부

// 댓글 수정 이벤트
replyList.addEventListener("click", function (e) {
    ... 생략 ...

    getReply(rno).then(reply => {

        console.log(reply)
        replyHeader.innerHTML = reply.rno
        modifyText.value = reply.replyText
        modifyModal.show()
        
        hasAuth = currentUser === reply.replyer // 댓글의 작성자와 현재사용자 일치 여부
    }).catch(e => alert('error'))
}, false)

댓글의 수정/삭제 버튼을 눌렀을때 이벤트에서 hasAuth변수를 이용해서 제어합니다.

modifyBtn.addEventListener("click", function (e) {
    
    if (!hasAuth){
        alert("댓글 작성자만 수정이 가능합니다.")
        modifyModal.hide()
        return
    }
    
   ... 생략 ...
   
}, false)
removeBtn.addEventListener("click", function (e){

    if (!hasAuth){
        alert("댓글 작성자만 삭제가 가능합니다.")
        modifyModal.hide()
        return
    }

    ... 생략 ...
    
}, false)

 

게시물 수정 처리

게시물 수정 작업은 현재 로그인한 사용자와 게시물의 작성자 정보가 일치할때만 삭제할수 있어야 합니다.

BoardController의 modify()에는 다음과 같이 @PreAuthorize()를 적용합니다.

@PreAuthorize("principal.username == #boardDTO.writer")
@PostMapping("/modify")
public String modify(...) {
 ...
}

 

AccessDeniedHandler

403(Forbidden)에러는 서버에서 사용자의 요청을 거부했다는 의미입니다.

스프링시큐리티에서 @PreAhthorize("isAuthenticated()")인 경우 사용자 로그인이 안되었다면 302메시지와함께 로그인경로로 이동하지만 403에러는 그렇지않습니다. 문제는 403에러가 생각보다 많다는 점입니다. 예를들어 현재 사용자가 권한이 없는경우일수도 있고

특정 조건이 맞지않는 경우일수도 있습니다. 이때 에러 페이지를 보여주는 방식 대신 AccessDeniedHandler인터페이스를 구현해서 상황에 맞게 처리하도록 해줍니다.

요청은 <form>을 통해서 전송된 결과를 처리하거나 Ajax를 이용하므로 이 두가지의 경우에 따라서 다르게 메시지를 처리하도록 합니다.

 

▶ <form>태그의 요청이 403인경우 로그인페이지로 이동할때 'ACCESS_DENIED'값을 파라미터로 같이 전달

▶ Ajax인 경우에는 JSON데이터를 만들어서 전송

 

security패키지에 handler패키지를 추가하고 Custom403Handler클래스를 추가합니다.

@Log4j2
public class Custom403Handler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        log.info("------------- ACCESS DENIED -------------");
        
        response.setStatus(HttpStatus.FORBIDDEN.value());
        
        // JSON 요청이었는지 확인
        String contentType = request.getHeader("Content-Type");
        
        boolean jsonRequest = contentType.startsWith("application/json");
        
        log.info("isJSON: " + jsonRequest);
        
        // 일반 request
        if (!jsonRequest) {
            response.sendRedirect("/member/login?error=ACCESS_DENIED");
        }
    }
}

추가한 Custom403Handler클래스는 AccessDeniedHandler인터페이스를 구현해서 403에러를 처리하기위해 사용합니다.

Custom403Handler가 동작하기위해서 시큐리티의 설정을 담당하는 CustomSecurityConfig에 빈처리와 예외처리를 지정합니다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    log.info("--------------- config ---------------");

    // 커스텀 로그인 페이지
    http.formLogin().loginPage("/member/login");
    // CSRF토큰 비활성화
    http.csrf().disable();

    http.rememberMe()
            .key("12345678")
            .tokenRepository(persistentTokenRepository())
            .userDetailsService(userDetailsService)
            .tokenValiditySeconds(60*60*24*30);
    
    http.exceptionHandling().accessDeniedHandler(accessDeniedHandler()); // 403

    return http.build();
}

@Bean
public AccessDeniedHandler accessDeniedHandler() {
    return new Custom403Handler();
}

위의 코드가 반영되면 403메시지가 화면에 출력되는 대신에 로그인페이지로 이동하고 'error=ACCESS_DENIED'가 전달됩니다.

 

게시물의 삭제 처리

과거에 게시물 삭제는 단순히 게시물 번호만을 전송해서 처리했지만 스프링시큐리티를 활용하려면 작성자를 추가로 설정하고 BoardController에서는 이를 이용해서 @PreAuthorize에서 활용하도록 구성합니다.

@PreAuthorize("principal.username == #boardDTO.writer")
@PostMapping("/remove")

 

만일 다른 사용자가 작성한 게시물을 삭제하려고 한다면 AccessDeniedHandler가 작성되어있으므로 로그인페이지로 리다이렉트됩니다.

 

 


2. 회원 데이터 처리

회원데이터는 가능하면 여러개의 권한을 가지도록 구성하는것이 좋기때문에 별도의 엔티티를 구성하는 대신에 하나의 엔티티 객체에 여러값을 표현할수 있는 @ElementCollection을 이용하도록 구성하고, enum타입을 사용해보겠습니다.

 

회원도메인과 Repository

데이터베이스에 회원 데이터를 생성하려면 회원 데이터를 어떤식으로 구성할것인지 결정해야합니다.

▶ 회원 아이디(mid) ▶ 탈퇴여부(del)
▶ 패스워드(mpw) ▶ 등록일 / 수정일 (regDate, modDate)
▶ 이메일(email) ▶ 소셜 로그인 자동 회원가입(social)

 

소셜로그인의 경우 별도의 회원가입 없이 SNS에서 인증된 사용자의 이메일을 회원아이디로 간주하고 회원데이터를 추가하도록 합니다.

(나중에 회원수정에서 패스워드 등을 수정하도록 구성)

각 회원은 'USER' 혹은 'ADMIN'권한을 가질수 있도록 @ElementCollection으로 처리합니다.

domain패키지에 Member엔티티클래스와 MemberRole를 선언합니다.(엔티티와 테이블의 이름을 결정할때 DB의 예약어로 지정X)

MemberRole은 특별한 속성을 가질 필요가 없기때문에 enum자체로만 선언하고 @Embeddable이 필요하지 않습니다.

package org.zerock.board.domain;

public enum MemberRole {
    
    USER, ADMIN;
    
}
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "roleSet")
public class Member {
    
    @Id
    private String mid;
    
    private String mpw;
    
    private String email;
    
    private boolean del;
    
    private boolean social;
    
    @ElementCollection(fetch = FetchType.LAZY)
    @Builder.Default
    private Set<MemberRole> roleSet = new HashSet<>();
    
    public void changePassword(String mpw) {
        this.mpw = mpw;
    }
    
    public void changeEmail(String email) {
        this.email = email;
    }
    
    public void changeDel(boolean del) {
        this.del = del;
    }
    
    public void addRole(MemberRole memberRole) {
        this.roleSet.add(memberRole);
    }
    
    public void clearRoles() {
        this.roleSet.clear();
    }
    
    public void changeSocial(boolean social) {
        this.social = social;
    }
}

 

MemberRepository와 테스트코드

repository에는 MemberRepository인터페이스를 추가하고 로그인시에 MemberRole을 같이 로딩할수 있는 메소드를 추가합니다.

다만 직접 로그인할때는 소셜서비스를 통해서 회원가입된 회원들이 같은 패스워드를 가지므로 일반 회원들만 가져오도록 social속성값이 false인 사용자들만을 대상으로 처리합니다.

public interface MemberRepository extends JpaRepository<Member, String> {
    @EntityGraph(attributePaths = "roleSet")
    @Query("select m from Member m where m.mid = :mid and m.social = false ")
    Optional<Member> getWithRoles(String mid);
}
@SpringBootTest
@Log4j2
public class MemberRepositoryTests {
    
    @Autowired
    private MemberRepository memberRepository;
    
    @Autowired
    private PasswordEncoder passwordEncoder;
    
    @Test
    public void insertMembers() {
        IntStream.range(1, 100).forEach(i -> {
            Member member = Member.builder()
                    .mid("member" + i)
                    .mpw(passwordEncoder.encode("1111"))
                    .email("email" + i + "@aaa.bbb")
                    .build();
            
            member.addRole(MemberRole.USER);
            if (i >= 90) {
                member.addRole(MemberRole.ADMIN);
            }
            memberRepository.save(member);
        });
    }
}

member테이블의 mpw값을 보면 동일하게 '1111'이라는 값을 인코딩했지만 매번 다른 문자열이 생성된것을 볼수 있습니다.

member_role_sets테이블에 member90이상인 계정들은 0과 1이라는 값을 가지게됩니다.(단순 enum의 경우 숫자로 처리됩니다.)

 

회원조회 테스트

@Test
public void testRead() {
    Optional<Member> result = memberRepository.getWithRoles("member100");
    
    Member member = result.orElseThrow();
    
    log.info(member);
    log.info(member.getRoleSet());
    
    member.getRoleSet().forEach(memberRole -> log.info(memberRole.name()));
}

 

회원서비스와 DTO처리

도메인으로 회원은 특별한 점이 없지만 시큐리티를 이용하는 경우 회원 DTO는 해당 API에 맞게 작성되어야하기 때문에 달라지는 부분이 많아집니다. 스프링시큐리티에서는 UserDetails라는 타입을 이용하기 때문에 일반적인 DTO와 조금 다르게 처리해야할 필요가 있습니다.

일반적인 DTO들과 달리 security패키지에 dto패키지를 구성하고 MemberSecurityDTO라는 클래스를 정의해서 스프링시큐리티에서 사용하는 UserDetails타입을 만족하도록 구성합니다.

@Getter
@Setter
@ToString
public class MemberSecurityDTO extends User {
    
    public String mid;
    public String mpw;
    public String email;
    public boolean del;
    public boolean social;
    
    public MemberSecurityDTO(String username, String password, String email, boolean del, boolean social, Collection<? extends GrantedAuthority> authorities){
        super(username, password, authorities);
        
        this.mid = username;
        this.mpw = password;
        this.email = email;
        this.del = del;
        this.social = social;
    }
}

MemberSecurityDTO는 org.springframework.security.core.userdetails.User라는 클래스를 부모클래스로 사용합니다.

User클래스는 UserDetails인터페이스를 구현한 클래스로 최대한 간단하게 UserDetails타입을 생성할수 있는 방법을 제공합니다.

 

CustomUserDetailsService의 수정

 실제 로그인처리를 담당하는 CustromUserDetailsService는 MemberRepository를 주입받아서 로그인에 필요한 MemberSecurityDTO를 반환하도록 수정되어야 합니다.

@Log4j2
@Service
@RequiredArgsConstructor // 추가
public class CustomUserDetailsService implements UserDetailsService {
    
    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        
        log.info("loadUserByUsername: " + username);

        Optional<Member> result = memberRepository.getWithRoles(username);
        
        if (result.isEmpty()) { // 해당 아이디를 가진 사용자가 없다면
            throw new UsernameNotFoundException("username not found...");
        }
        
        Member member = result.get();

        MemberSecurityDTO memberSecurityDTO = new MemberSecurityDTO(
                member.getMid(),
                member.getMpw(),
                member.getEmail(),
                member.isDel(),
                false,
                member.getRoleSet().stream().map(memberRole -> new SimpleGrantedAuthority("ROLE_" + memberRole.name()))
                .collect(Collectors.toList())
        );
        log.info("memberSecurityDTO");
        log.info(memberSecurityDTO);
        
        return memberSecurityDTO;
    }
}

변경된 CustomUserDetailsService가 반영되면 로그인시에 데이터베이스에 존재하는 mid와 패스워드를 이용해서 실제 로그인 처리가 가능해집니다. 만일 로그인 과정에 문제가 있다면 '/member/login?error'의 경로로 이동하게됩니다.

 

회원도메인과 연관관계

Member도메인을 Board나 Reply와 연결해서 @ManyToOne으로 처리하는것도 가능하지만 연관관계를 설정하지 않은 이유는 최근 유행하는 마이크로 서비스 아키텍처(이하 MSA)를 염두에 두기 때문입니다.

(MSA는 쉽게말해 '여러개의 독립서비스들을 연계해서 하나의 큰 서비스를 구성한다'라는 단순한 아이디어입니다. MSA의 반대개념은 모든 서비스가 통합되는 모놀리식 아키텍처입니다. 모놀리식은 하나의 서비스에 회원과 주문등이 모두 같은 컨텍스트로 구성되지만, MSA는 별개의 서비스로 운영됩니다.)

 

회원 가입 처리

지금은 데이터베이스에 이미 추가된 계정만으로 로그인이 가능하므로, 회원가입을 통해서 직접 계정을 생성할수 있도록 구성해보겠습니다.

회원가입은 '/member/join'경로를 이용, GET방식으로 회원가입페이지, POST방식으로 데이터베이스에 추가하도록 구성합니다.

 

스프링시큐리티가 사용하는 DTO와는 별개로 회원가입에 사용하는 DTO는 MemberJoinDTO라는 이름의 클래스를 구성해서 처리합니다.

일반적인 회원의 경우 'ADMIN'이 아닌 단순 'USER'이므로 이에 대해서는 별도의 화면구성이 필요하지 않습니다.

dto패키지에 MemberJoinDTO를 추가합니다. 직접 회원가입하는 경우는 소셜회원을 의미하는 social의 값이 false입니다.

@Data
public class MemberJoinDTO {
    
    private String mid;
    private String mpw;
    private String email;
    private boolean del;
    private boolean social;
}

MemberController에는 GET방식과 POST방식에 대한 메소드를 다음과 같이 추가합니다.

@GetMapping("/join")
public void joinGET() {
    log.info("join get...");
    
}

@PostMapping("/join")
public String joinPOST(MemberJoinDTO memberJoinDTO) {
    log.info("join post...");
    log.info(memberJoinDTO);
    
    return "redirect:/board/list";
    
}

MemberController는 아직 서비스계층이 작성되지 않았으므로 단순히 파라미터의 수집을 확인하는 용도로만 작성합니다.

 

templates내 meber폴더에 join.html을 작성합니다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5"
      layout:decorate="~{layout/basic.html}">
<head>
    <title>Member Join Page</title>
</head>

<div layout:fragment="content">
    <div class="row mt-3">
        <div class="col">
            <div class="card">
                <div class="card-header">
                    JOIN
                </div>
                <div class="card-body">
                    <form id="registerForm" action="/member/join" method="post">
                        <div class="input-group mb-3">
                            <span class="input-group-text">MID</span>
                            <input type="text" name="mid" class="form-control">
                        </div>

                        <div class="input-group mb-3">
                            <span class="input-group-text">MPW</span>
                            <input type="password" name="mpw" class="form-control">
                        </div>

                        <div class="input-group mb-3">
                            <span class="input-group-text">EMAIL</span>
                            <input type="email" name="email" class="form-control">
                        </div>

                        <div class="my-4">
                            <div class="float-end">
                                <button type="submit" class="btn btn-primary submitBtn">Submit</button>
                                <button type="reset" class="btn btn-secondary">Reset</button>
                            </div>
                        </div>
                    </form>
                </div><!--end Cardbody-->
            </div><!--end Card-->
        </div><!--end col-->
    </div><!--end row-->
</div><!--end content-->

<script layout:fragment="script" th:inline="javascript">
    
</script>

실행 후 '/member/join'을 호출면 회원가입화면을 볼수있고, POST방식 후에는 '/board/list'로 이동하게 됩니다.

 

회원가입 서비스계층 관리

service패키지에 MerberService인터페이스와 MemberServiceImpl클래스를 추가합니다.

회원가입에서 신경써야하는것은 이미 아이디가 존재하는 경우 MemberRepository의 save()는 insert가 아니라 update로 실행합니다.

만일 같은 아이디가 존재하면 예외를 발생하도록 처리합니다. 발생하는 예외는 인터페이스 내부에 선언해서 사용합니다.

public interface MemberServie {
    
    static class MidExistException extends Exception {}
    
    void join(MemberJoinDTO memberJoinDTO) throws MidExistException ;
}

MidExistException이라는 예외를 static클래스로 선언해서 필요한곳에서 사용하도록 합니다.

@Log4j2
@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberServie {
    
    private final ModelMapper modelMapper;
    
    private final MemberRepository memberRepository;
    
    private final PasswordEncoder passwordEncoder;
    
    @Override
    public void join(MemberJoinDTO memberJoinDTO) throws MidExistException {
        
        String mid = memberJoinDTO.getMid();
        
        boolean exist = memberRepository.existsById(mid);
        
        if (exist) {
            throw new MidExistException();
        }
        
        Member member = modelMapper.map(memberJoinDTO, Member.class);
        member.changePassword(passwordEncoder.encode(memberJoinDTO.getMpw()));
        member.addRole(MemberRole.USER);
        
        log.info("member : " + member);
        log.info(member.getRoleSet());
        
        memberRepository.save(member);
        
    }
}
@Controller
@RequestMapping("/member")
@Log4j2
@RequiredArgsConstructor
public class MemberController {
    
    // 의존성 주입
    private final MemberServie memberServie;

    @GetMapping("/login")
    public void loginGET(String error, String logout) {
        log.info("login get.......");
        log.info("logout : " + logout);

        if (logout != null) {
            log.info("user logout....");
        }
    }

    @GetMapping("/join")
    public void joinGET() {
        log.info("join get...");
    }

    @PostMapping("/join")
    public String joinPOST(MemberJoinDTO memberJoinDTO, RedirectAttributes redirectAttributes) {
        log.info("join post...");
        log.info(memberJoinDTO);
        try {
            memberServie.join(memberJoinDTO);
        } catch (MemberServie.MidExistException e) {
            redirectAttributes.addFlashAttribute("error", "mid");
            return "redirect:/member/join";
        }
        redirectAttributes.addFlashAttribute("result", "success");
        return "redirect:/member/login"; // 회원가입 후 로그인
    }
}
<script layout:fragment="script" th:inline="javascript">
    const error = [[${error}]]
    
    if (error && error === 'mid'){
        alert("동일한 MID를 가진 계정이 존재합니다.")
    }

</script>

 

반응형