이전글

 

[Spring Security] 스프링 시큐리티 이해하기 (1)

Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Sprin

tmd8633.tistory.com

 

이전글에서 Spring Security 기본개념과 구조이해를 했습니다. 이번 글에서는 Spring Security를 적용해보도록 하겠습니다.

 

 

dependencies

build.gradle에 의존성을 주입해줍니다.

implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

 

테스트 환경을 위해 타임리프도 추가했습니다.

 

 

 

시작하기

<!-- index.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span>메인페이지</span>
</body>
</html>

 

@Controller
public class MainController {

    @GetMapping
    public String mainPage() {
        return "index";
    }
}

 

이렇게 실행하고 접속해보면

 

 설정하지 않은 로그인 페이지가 뜨고, /login 으로 이동되었습니다. 이는 스프링 시큐리티에서 웹 보안에 가장 기본적인 로그인기능을 지원하기 때문에 나오는건데요.

 

로그에 표시된 password와 username에 'user' 라고 적고 로그인 해봅시다.

 

localhost:8080

성공 적으로 메인페이지로 넘어왔습니다. 우리는 기본적으로 제공하는 로그인 페이지를 사용하지 않고 로그인을 구현할것이기 때문에 일단 모든 요청을 허용해놓겠습니다.

 

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.authorizeHttpRequests(request ->
                // 어떤요청이든(anyRequest) 모두허용(permitAll)하겠다.
                request.anyRequest().permitAll()
            );

        return http.build();
    }

}

 

 

 

로그인

 

Security LoginForm 적용 전

 

HTML

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span>메인페이지</span>

    <a href="/join">로그인</a>
    <a href="/signup">회원가입</a>
</body>
</html>
<!-- joinForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form th:action="@{/join}" th:method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <input type="submit" value="로그인">
    </form>
</body>
</html>
<!-- signupForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form th:action="@{/signup}" method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <input type="text" name="name">
        <input type="submit" value="회원가입">
    </form>
</body>
</html>
<!-- userForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span th:text="${user + '님 안녕하세요'}"></span>
</body>
</html>

메인페이지, 로그인, 회원가입, 유저페이지를 구성했습니다.

유저페이지는 로그인 한 유저의 정보를 볼 수 있는 페이지입니다.

 

Domain

@Getter @Setter
@ToString
public class User {

    private String username;
    private String password;
    private String role;
    private String name;
}

User에는 아이디, 비밀번호, 권한, 이름을 저장합니다.

 

 

MainController

@Controller
public class MainController {

    @GetMapping
    public String mainPage() {
        return "index";
    }

    @GetMapping("/user")
    public String userPage(@SessionAttribute("user") String username, Model model) {
        model.addAttribute("user", username);
        return "userForm";
    }

    @GetMapping("/join")
    public String loginPage() {
        return "joinForm";
    }

    @GetMapping("/signup")
    public String signupPage() {
        return "signupForm";
    }
}

HTML과 매핑해줍니다.

 

UserController

@Controller
@RequiredArgsConstructor
public class UserController {

    private final MemoryDB db;
    private final BCryptPasswordEncoder encoder;

    @PostMapping("/signup")
    public String signup(User user) {
        user.setRole("ROLE_USER");
        db.save(user);
        return "redirect:/join";
    }

    @PostMapping("/join")
    public String login(HttpServletRequest request, User user) {
        User findUser = db.find(user.getUsername());
        if (findUser == null || !encoder.matches(user.getPassword(), findUser.getPassword())) {
            return "redirect:/join";
        }
        request.getSession().setAttribute("user", findUser.getUsername());
        return "redirect:/user";
    }
}

로그인과 회원가입 POST 요청을 처리합니다.

 

DB

@Component
@RequiredArgsConstructor
public class MemoryDB {

    private final Map<String, User> db = new HashMap<>();
    private final BCryptPasswordEncoder encoder;

    public User save(User user) {
        user.setPassword(encoder.encode(user.getPassword()));
        db.put(user.getUsername(), user);
        return user;
    }

    public User find(String username) {
        return db.get(username);
    }
}

DB에 유저정보를 저장하고 간단한 예제 구현을 위해 Key값을 아이디로 잡았습니다.

 

 

Config

    @Bean
    BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

비밀번호는 암호화를 위해 BCryptPasswordEncode를 사용했습니다.

 

이렇게 하면 잘 동작할겁니다. 실제로는 예외처리등 수 많은 로직이 존재할겁니다. 이걸 Spring Security에게 위임해보겠습니다.

 

 

 

Security LoginForm 적용 후

먼저 UserController에서 우리가 로그인을 직접 구현했던 @PostMapping("/join") 부분을 제거해줍니다.

@Controller
@RequiredArgsConstructor
public class UserController {

    private final MemoryDB db;
    private final BCryptPasswordEncoder encoder;

    @PostMapping("/signup")
    public String signup(User user) {
        user.setRole("ROLE_USER");
        db.save(user);
        return "redirect:/join";
    }

}

 

 

@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(user.getRole()));
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

UserDetails 를 구현해줍니다. 여기에 우리가 구현한 User를 멤버변수로 받고 getUsername과 getPassword를 이어줍니다.

나머지는 일단 true로 둡시다.

 

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final MemoryDB db;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User findUser = db.find(username);
        if (findUser == null) {
            throw new UsernameNotFoundException("아이디 또는 비밀번호가 일치하지 않습니다.");
        }
        return new PrincipalDetails(findUser);
    }
}

 

UserDetailsService를 구현하고 DB와 연동해줍니다. loadUserByUsername의 반환값은 위에서 구현한 PrincipalDetails로 해주고 User 객체를 넣어줍니다.

 

 

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.formLogin(login -> login
            .loginPage("/join")
            .loginProcessingUrl("/join")
            .defaultSuccessUrl("/user")
        );

        http.authorizeHttpRequests(request ->
            request.anyRequest().permitAll()
        );

        return http.build();
    }

SecurityConfig에서 formLogin을 작성해줍니다. formLogin().authorizeHttpRequests() 이렇게 이을 수 있는데 전 따로 분리하는게 더 보기 편하더라구요.

 

메소드 설명
loginPage 로그인 페이지 URL 설정
loginProcessingUrl 로그인 처리 URL 설정 (form action 값)
defaultSuccessUrl 로그인 성공 시 이동할 URL, 두번째 인자인 boolean alwaysUse false가 default
false : 사용자가 보호된 페이지 접근 시도 후 로그인 → 시도했던 페이지로 이동
true : 무조건 defaultSuccessUrl로 설정된 URL로 이동
failureUrl 로그인 실패 시 이동할 URL
usernameParameter 아이디 파라미터명 설정, username이 default
form input name이 username이 아닌경우 (예, email, account 등) 설정
passwordParameter 비밀번호 파라미터명 설정, password가 default
successHandler 로그인 성공 핸들러
failureHandler 로그인 실패 핸들러
disabled 사용안함

 

 

이렇게 설정하고

    @GetMapping("/user")
    public String userPage(@AuthenticationPrincipal PrincipalDetails principalDetails, Model model) {
        model.addAttribute("user", principalDetails.getUser().getName());
        return "userForm";
    }

SessionAttribute를 제거하고 SecurityContextHolder에 저장된 인증된 사용자의 데이터를 호출하는 @AuthenticationPrincipal을 이용해 데이터를 불러옵니다.

회원가입

 

userForm

로그인 로직을 직접구현하지 않고도 정상적으로 작동되었습니다.

 

 

 

권한설정

여기서 문제가 있습니다.

 

로그인을 하지 않고 /user 로 접속하니 500 에러가 발생했습니다.

java.lang.NullPointerException: Cannot invoke "com.security.demo.config.security.PrincipalDetails.getUser()" because "principalDetails" is null

 

PrincipalDetails에 user를 넣는과정에서 user = null 이 되었기때문에 문제가 발생한건데요. null check를 한다고해도 

로그인하지 않은 사용자가 접근이 가능하다는 것입니다. 이 문제를 해결해보도록 하겠습니다.

User 객체에는 Role 이라는 데이터가 있었습니다. 그리고 회원가입한 User는 "ROLE_USER" 를 부여받습니다. 이 데이터를 이용하겠습니다.

 

몇 가지 가정을 해보겠습니다.

  1. 권한에는 일반 유저("USER")와 관리자("ADMIN") 이 있습니다.
  2. 유저정보를 볼 수 있는 '/user' 는 일반유저와 관리자가 접근할 수 있고,
    관리자정보를 볼 수 있는 '/admin'은 관리자만 접근할 수 있습니다.

 

먼저 관리자 페이지를 간단하게 만들겠습니다.

<!-- adminForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span th:text="${admin + '관리자님 안녕하세요'}"></span>
</body>
</html>
    @GetMapping("/admin")
    public String adminPage(@AuthenticationPrincipal PrincipalDetails principalDetails, Model model) {
        model.addAttribute("admin", principalDetails.getUser().getName());
        return "adminForm";
    }

 

 

이제 SecurityConfig를 수정해보겠습니다.

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.formLogin(login -> login
            .loginPage("/join")
            .loginProcessingUrl("/join")
            .defaultSuccessUrl("/user")
            .failureUrl("/join?error")
        );

        http.authorizeHttpRequests(request -> request
            .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().permitAll()
        );

        return http.build();
    }

 

/user/** 에 접근하려면 "USER" 또는 "ADMIN" 권한이 존재해야합니다.

/admin/** 에 접근하려면 "ADMIN" 권한이 존재해야합니다.

 

이 권한 PrincipalDetails에 getAuthorities() 에 구현한 데이터를 기반으로 동작합니다. 그런데 우리는 "ROLE_USER" 를 넣었지 "USER"를 넣지는 않았습니다. 그 이유는 GrantedAuthority 기본 규칙은 "ROLE_" 접두사를 사용하는것이 default 이기 때문입니다.

 

조금 더 알고싶거나 "ROLE_" 접두사는 변경하고싶으면

 

 

[Spring Security] 스프링 시큐리티 이해하기 (1)

Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Sprin

tmd8633.tistory.com

이 글 4. Authorization (인가) 부분을 참고하시기 바랍니다.

 

메소드 설명
requestMatchers(String... patterns)
requestMatchers(HttpMethod method, String... patterns)
requestMatchers(HttpMethod method)
특정 URL 패턴에 대한 접근 설정
requestMatchers("/user/**") : /user 하위 URL에 대한 접근 설정
requestMatchers(HttpMethod.GET, "/user"/**") : /user 하위 URL에 GET 접근에 대한 설정
requestMatchers(HttpMethod.GET) : GET 접근에 대한 설정
authenticated() 인증된 사용자만 접근 허용
permitAll() 모든 사용자 접근 허용
denyAll() 모든 접근 거부
hasAuthority(String authority) 특정 권한을 가진 사용자만 접근
hasAuthority("USER") -> "USER" 접근
hasAnyAuthority(String... authorities) 여러 권한 중 하나라도 가진 사용자만 접근
hasRole(String role) 특정 권한을 가진 사용자만 접근 (ROLE_ 접두사 자동 추가)
hasRole("USER") -> "ROLE_USER" 에 접근
hasAnyRole(String... roles) 여러 역할 중 하나라도 가진 사용자 접근 (ROLE_ 접두사 자동 추가)

 

 

결과

로그인이 되어있지 않은 유저가 /user 로 접근하는 경우 /join 으로 redirect되어 로그인이 진행된 후에 /user 접근이 됩니다.

만약 USER가 ADMIN에 접근하려는 경우 403 Forbidden 이 발생합니다.

 

 

로그아웃

<!-- userForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span th:text="${user + '님 안녕하세요'}"></span>
    <form th:action="@{/logout}" th:method="post">
        <input type="submit" th:value="로그아웃">
    </form>
</body>
</html>

userForm.html에서 로그아웃 form을 만들어줍니다.

 

        http.logout(logout -> logout
            .logoutUrl("/logout")
            .logoutSuccessUrl("/")
        );

http.logout을 설정해주면 로그아웃도 완성입니다.

 

로그아웃 설정

메소드 설명
logoutURL 로그아웃 처리 URL default : "/logout"
logoutSuccessUrl 로그아웃 성공 시 이동할 URL
logoutSuccessHandler 로그아웃 성공 시 핸들러
addLogoutHandler 로그아웃 핸들러 추가
clearAuthentication SecurityContext에서 인증정보 제거 default : true
invalidateHttpSession 세션 무효화 default true
deleteCookies(String... cookieNamesToClear) 삭제할 쿠키 지정
logoutRequestMatcher 로그아웃 요청 매처 설정
예) GET으로 로그아웃 요청 시 .logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))

 

 

결론

Spring Security에서 가장 기본적으로 다루는 부분을 구현해보았습니다.  아직 알아야할거도 많고 추가된것들도 많아서 공부를 계속해 나가면서 시리즈 더 추가해보겠습니다.

 

+ Recent posts