[Spring] Spring Security 설정 및 구현(Session)_Spring boot3.0.0 이상

wooSim·2023년 6월 14일
8

1. Spring Security 란?


□ Spring Security란

Spring Security는 Spring 기반 애플리케이션의 보안(인증과 권한 등)을 담당하는 프레임 워크입니다. Spring Security는 인증과 권한에 대해 Filter 흐름에 따라 처리하고 있으며 보안과 관련해서 많은 옵션을 제공해주고 있기 때문에 개발자가 일일이 보안 로직을 작성하지 않아도 된다는 장점이 있습니다.

▷ 인증(Authorizatoin)과 인가(Authentication)

  • 인증(Authentication) : 사용자가 누구인지 확인하는 절차(ex. login)
  • 인가(Authentication) : 인증된 사용자가 요청한 자원에 접근 가능한지 권한을 확인하는 절차



2. Spring Security 기본 설정


소스코드: github

Spring Security는 OAuth2(소셜 로그인), JWT(Json Web Token) 등 다양한 로그인에 대해 제공합니다. 해당 글에서는 이러한 방법 보다는 기본적인 설정을 통해 인증과 인가에 대해 알아보고자 합니다.

스프링부트 3.0 부터 스프링 시큐리티 6.0.0 이상의 버전이 적용되어있습니다. 해당 Security 버전에서는 antMatchers, WebSecurityConfigurerAdapter 등이 deprecated되어 쓰니는 Spring boot 3.1.0 버전으로 작성하였습니다.

▷ 사용 기술

  • Framework: Spring Boot 3.1.0, Spring Security 6.1.0
  • 개발 언어 : java 17
  • view : mustache
  • ORM : JPA
  • Database: H2

▷ gradle(security 적용 전)
프로젝트를 생성하고 build.gradle의 dependencies를 다음과 같이 설정하였습니다.


□ 1. 프로젝트 view 구성(Spring Security 적용 전)

▷ 메인 화면

  • 권한에 상관없이 접근이 가능한 화면, 로그인을 안해도 주소만 알면 접근이 가능.

▷ 사용자 화면

  • 메인 화면에서 '사용자 컨텐츠 이동' 버튼 클릭시 이동되는 화면
  • 로그인한 사용자만 접근이 가능한 화면(USER 권한)

▷ 관리자 화면

  • 사용자 화면에서 '관리자'버튼 클릭시 이동되는 화면
  • ADMIN 권한이 있어야만 접근이 가능한 화면

▷ 로그인 및 회원가입 화면

  • 권한에 상관없이 접근이 가능한 화면,
    인증을 하기 위해 로그인을 시도할려는데 인증이 되지 않아서 접근이 불가능 하면 안되겠죠?

현재 모든 화면은 Spring Security 적용 전이기 때문에 접근이 가능합니다.

□ 2. Spring Security 적용

Spring Security를 사용하기 위해 dependenciy를 받습니다.

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

서버를 다시 시작하면 Spring Security dependenciy를 받은 해당 애플리케이션은 어떠한 요청이든 인증을 해야만 응답을 받을 수 있습니다.

해당 화면은 기본적으로 제공해주는 로그인 페이지 입니다.

아직 추가 설정을 하지 않았다면
username 에는 'user', password에는 아래 이미지의 임시 비밀번호를 적어주시면 로그인 가능합니다.

로그인을 하면 아직 아무 설정도 하지 않았기 때문에 Security를 적용하기 전과 마찬가지로 어떠한 자원이든 접근이 가능합니다..


이제 Spring Security를 적용해보도록 하겠습니다!!

▷ SecurityConfig 자원별 접근 권한 설정
폴더 구성을 아래와 같이 구성하였습니다.

이제 config패키지에서 Spring Security 설정을 하겠습니다.

코드를 간단히 설명하자면

  1. csrf(Cross site Request forgery) 설정을 disable 하였습니다.
  2. h2-console 화면을 사용하기 위해 해당 옵션들을 disable 하였습니다.
  3. 프로젝트 view 구성에서 보았듯이 자원 요청 별 권한 설정을 하였습니다.
    위에서부터 순서대로
    • h2-console을 사용하기 위한 설정(스프링 시큐리티 6.0.0 이상 버전 부터 저 설정을 하지 않으면 h2-console에 들어갈 수 없었습니다..)
    • 메인화면과 로그인 및 회원가입 화면은 권한에 상관없이 접근할 수 있어야 하기에 permitAll로 모든 접근을 허용하였습니다.
    • posts 관련 요청은 로그인 인증을 하여 USER 권한을 획득한 사용자만 접근 할 수 있기에 hasRole(Role.USER.name()) 설정을 하였습니다.
    • admin 관련 요청은 ADMIN 권한이 있어야 접근이 가능하기에 hasRole(Role.ADMIN.name()) 설정을 하였습니다.

참고로 @EnableWebSecurity는 모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 애너테이션으로 @EnableWebSecurity 애너테이션을 사용하면 내부적으로 SpringSecurityFilterChain이 동작하여 URL 필터가 적용됩니다.

이제 domain패키지 하위에 member 패키지를 생성하고 그 밑에 Role Enum을 만들어 줍니다.


다시 서버를 재기동 하면 permitAll() 설정한 요청은 로그인 없이 접근이 가능하게 되었습니다.

하지만 USER 권한이 필요한 '사용자 컨텐츠 이동' 버튼으로 /posts 요청에 대해서는 접근이 거부 되었습니다...


▷ SecurityConfig Exception 설정

그런데 이상한 부분이 있습니다! 로그인을 하지 않았다는 것은 401(Unauthorized)인증 관련 HTTP 상태 코드가 나와야하는데 403(Forbidden) 권한 관련 상태코드를 응답해주고 있습니다.
이유는 401(Unauthorized) 관련 인증 예외처리를 해주지 않으면 Spring security에서는 인가 예외로 발생시키기 때문이다.

자세한 내용은 401( Unauthorized) VS 403(Forbidden) HTTP 상태 비교를 봐주시면 감사하겠습니다.


@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .csrf((csrfConfig) ->
                        csrfConfig.disable()
                ) // 1번
                .headers((headerConfig) ->
                        headerConfig.frameOptions(frameOptionsConfig ->
                                frameOptionsConfig.disable()
                        )
                )// 2번
                .authorizeHttpRequests((authorizeRequests) ->
                        authorizeRequests
                                .requestMatchers(PathRequest.toH2Console()).permitAll()
                                .requestMatchers("/", "/login/**").permitAll()
                                .requestMatchers("/posts/**", "/api/v1/posts/**").hasRole(Role.USER.name())
                                .requestMatchers("/admins/**", "/api/v1/admins/**").hasRole(Role.ADMIN.name())
                                .anyRequest().authenticated()
                )// 3번
                .exceptionHandling((exceptionConfig) ->
                        exceptionConfig.authenticationEntryPoint(unauthorizedEntryPoint).accessDeniedHandler(accessDeniedHandler)
                ); // 401 403 관련 예외처리

        return http.build();
    }

    private final AuthenticationEntryPoint unauthorizedEntryPoint =
            (request, response, authException) -> {
                ErrorResponse fail = new ErrorResponse(HttpStatus.UNAUTHORIZED, "Spring security unauthorized...");
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                String json = new ObjectMapper().writeValueAsString(fail);
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                PrintWriter writer = response.getWriter();
                writer.write(json);
                writer.flush();
            };

    private final AccessDeniedHandler accessDeniedHandler =
            (request, response, accessDeniedException) -> {
                ErrorResponse fail = new ErrorResponse(HttpStatus.FORBIDDEN, "Spring security forbidden...");
                response.setStatus(HttpStatus.FORBIDDEN.value());
                String json = new ObjectMapper().writeValueAsString(fail);
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                PrintWriter writer = response.getWriter();
                writer.write(json);
                writer.flush();
            };

  	@Getter
    @RequiredArgsConstructor
    public class ErrorResponse {

        private final HttpStatus status;
        private final String message;
    }
}

SecurityConfig에 인증, 인가 관련 예외처리를 하고 다시 /post url 자원에 접근하면 Unauthorized 인증 관련 에러가 발생하는 것을 볼 수 있습니다.


▷ SecurityConfig login 및 logout 설정
로그인, 로그아웃 설정을 진행해보도록 하겠습니다.

formLogin을 통해 login 설정을 할 수 있습니다.

  1. login 화면 url를 설정하였습니다. 기본적으로 "/login" url을 가지며 해당 url을 사용할 경우 Security에서 제공(처음에 아무 설정하지 않은 login form)하는 화면을 사용하며, 해당 옵션으로 커스텀마이징할 수 있습니다.
  2. 로그인 ID json 키 값을 설정하면 됩니다. 해당 옵션을 설정하지 않으면 "username"로 설정되고 위 사진에서는 일부러 커스텀하는 것을 보여주기 위해 설정하였습니다.
  3. 로그인 password json 키 값을 설정하면 됩니다. 설정하지 않으면 "password"로 설정됩니다.
  4. 로그인 submit 요청을 받을 URL 입니다.
  5. 로그인에 성공했을때 이동할 URL 입니다.
  6. 로그아웃에 성공했을때 이동하는 URL 입니다. 기본적으로 "/logout"입니다.
  7. submit을 통해 설정한 "/login/login-proc" 요청을 받으면 Spring Security는 요청을 받아 7번의 서비스 로직을 수행합니다.

위의 소스코드를 추가합니다.

  • login.mustache
<h2>로그인 화면 </h2>
<form class="form-signin" method="post" action="/login/login-proc">

    <input type="text" id="username" name="username" class="form-control" placeholder="아이디" autofocus="" />
    <input type="text" id="password" name="password" class="form-control" placeholder="비밀번호" />

    <input id="joinBtn" type="submit" class="btn btn-secondary active" value="로그인" />
    <a href="/login/join" class="btn btn-info active" role="button">회원가입</a>

</form>
  • MyUserDetailsService.class
  @RequiredArgsConstructor
@Service
public class MyUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("없는 회원 입니다..."));

        return User.builder().username(member.getUsername()).password(member.getPassword()).roles(member.getRole().name()).build();
    }
}

MyUserDetailsService.class는 UsernameNotFoundException 상속받아 loadUserByUsername을 구현 합니다. loadUserByUsername는 로그인 화면에서 submit한 "/login/login-proc"요청(loginProcessingUrl)에서 username을 파라미터로 넘겨 username이 DB에 있는 회원인지 확인한 뒤 User객체를 생성합니다.
주의!! 해당 예제의 loadUserByUsername에서 return한 User객체는 구현한 객체가 아닌 UserDetails를 상속받은 Spring Security 에서 제공하는 객체 입니다. 물론 UserDetails를 직접 implements 받아서 구현하셔도 됩니다.😊


▷ 회원가입 구현
회원 가입 구현은 Spring Security 내용이 아니니 소스코드 소개하고 넘어가겠습니다.

  • join.mustache
<h2>회원가입 화면 </h2>
<form class="form-signin" method="post" action="/login/join">

    <span><input type="text" id="username" name="username" class="form-control" placeholder="아이디" autofocus="" /></span>
    <br><br>
    <input type="text" id="password" name="password" class="form-control" placeholder="비밀번호" />

    <br><br><br>
    <div style="text-align: center">
        <input id="joinBtn" style="display :inline-block;"type="submit" class="btn btn-info active" value="회원가입" />
    </div>
</form>
  • MemberJoinRequestDto.class
@Getter
public class MemberJoinRequestDto {
    private String username;

    private String password;

    @Builder
    public MemberJoinRequestDto(String username, String password) {
        this.username = username;
        this.password = password;
    }

    public Member toEntity(){
        return Member.builder()
                .username(username)
                .password(password)
                .role(Role.USER)
                .build();
    }
}
  • IndexController.class
...
  
@PostMapping("/login/join")
public String userJoin(@ModelAttribute MemberJoinRequestDto requestDto) {
    memberService.addUser(requestDto);
    return "login/login";
}
...
  • MemberService.class
@RequiredArgsConstructor
@Service
public class MemberService {

    private final MemberRepository memberRepository;

    public String addUser(MemberJoinRequestDto requestDto) {
        return memberRepository.save(requestDto.toEntity()).getUsername();
    }
}

위 코드를 구현하고 회원가입을 시도하면 아래와 같이 회원가입이 완료됩니다.


▷ 비밀번호 암호화 알고리즘 적용
Database에 비밀번호를 암호화하지 않고 저장하는 것은 아주 위험합니다. 회원가입 시 비밀번호를 암호화해서 넣어주고 로그인할때도 암호화된 비밀번호를 비교하도록 해야합니다.
SecurityConfig에 passwordEncoder Bean을 등록하도록 하겠습니다.

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final MyUserDetailsService myUserDetailsService;

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

config 패키지에 SimplePasswordEncoder.class 파일을 추가해주도록 하겠습니다.

public class SimplePasswordEncoder implements PasswordEncoder {
    @Override
    public String encode(CharSequence rawPassword) {
        return rawPassword.toString();
    }

    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return encodedPassword.equals(encode(rawPassword));
    }
}  

SimplePasswordEncoder에서 rawPassword는 사용자가 입력한 비밀번호이며, 구현한 encode와 matches의 기능은 다음과 같습니다.
encode : 해당 암호화 방식으로 암호화한 문자열을 리턴해줍니다. 회원가입 시 DB에 넣기전에 사용하면 됩니다.
matches : loadUserByUsername 에서 UserDetails에 넣어준 password() 부분이 여기로 들어옵니다. 들어온 비밀번호는 암호화되어 DB에 저장된 암호화된 비밀번호와 같은지 확인할 수 있습니다.


이제 회원가입할때에도 암호화를 할 수 있도록 코드를 수정하도록 하겠습니다.

  • MemberService.class
@RequiredArgsConstructor
@Service
public class MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    public String addUser(MemberJoinRequestDto requestDto) {
        return memberRepository.save(requestDto.toEntity()).getUsername();
    }
}
  • MemberJoinRequestDto.class
@Getter
public class MemberJoinRequestDto {
    private String username;

    private String password;

    @Builder
    public MemberJoinRequestDto(String username, String password) {
        this.username = username;
        this.password = password;
    }

    public Member toEntity(PasswordEncoder passwordEncoder){
        return Member.builder()
                .username(username)
                .password(passwordEncoder.encode(password))
                .role(Role.USER)
                .build();
    }
}

DB를 다시 확인해보면 password가 암호화된 채 들어간 것을 확인할 수 있습니다.

SimplePasswordEncoder.class를 통해 DB 비밀번호와 로그인 시 입력한 password를 비교하기 때문에 로그인도 문제없이 이루어 지는 것을 볼 수 있습니다.


▷ 권한 확인
처음 SecurityConfig를 설정할때 /posts와 관련된 요청은 USER권한이 있어야만 접근이 가능하게 설정하였습니다.
회원가입 시 기본으로 USER 권한이 들어가도록 코드를 작성하였기 때문에 /posts에 대한 접근이 가능한 것을 확인할 수 있습니다.

여기서 관리자 버튼을 클릭할 경우 /admins 와 관련된 요청은 ADMIN 권한이 있어야 가능하게 설정하였기 때문에 403(Forbidden) 응답을 받게 됩니다.

만약 유저정보를 가져오고 싶다면 @AuthenticationPrincipal 애너테이션을 통해 UserDetails의 정보를 가져올 수 있다.

@GetMapping("/posts")
public String posts(@AuthenticationPrincipal UserDetails user, Model model){
    model.addAttribute("user",user.getUsername());
    return "post/post";
}



간단하게 구현을 마친 SecurityConfig.class의 전체 소스코드입니다.

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final MyUserDetailsService myUserDetailsService;

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .csrf((csrfConfig) ->
                        csrfConfig.disable()
                ) // 1번
                .headers((headerConfig) ->
                        headerConfig.frameOptions(frameOptionsConfig ->
                                frameOptionsConfig.disable()
                        )
                )// 2번
                .authorizeHttpRequests((authorizeRequests) ->
                        authorizeRequests
                                .requestMatchers(PathRequest.toH2Console()).permitAll()
                                .requestMatchers("/", "/login/**").permitAll()
                                .requestMatchers("/posts/**", "/api/v1/posts/**").hasRole(Role.USER.name())
                                .requestMatchers("/admins/**", "/api/v1/admins/**").hasRole(Role.ADMIN.name())
                                .anyRequest().authenticated()
                )// 3번
               .exceptionHandling((exceptionConfig) ->
                        exceptionConfig
                                .authenticationEntryPoint(unauthorizedEntryPoint)
                                .accessDeniedHandler(accessDeniedHandler)
                ) // 401 403 관련 예외처리
                .formLogin((formLogin) ->
                        formLogin
                                .loginPage("/login/login") 
                                .usernameParameter("username") 
                                .passwordParameter("password") 
                                .loginProcessingUrl("/login/login-proc") 
                                .defaultSuccessUrl("/", true) 
                )
                .logout((logoutConfig) ->
                        logoutConfig.logoutSuccessUrl("/") 
                )
                .userDetailsService(myUserDetailsService); 

        return http.build();
    }


    public final AuthenticationEntryPoint unauthorizedEntryPoint =
            (request, response, authException) -> {
                ErrorResponse fail = new ErrorResponse(HttpStatus.UNAUTHORIZED, "Spring security unauthorized...");
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                String json = new ObjectMapper().writeValueAsString(fail);
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                PrintWriter writer = response.getWriter();
                writer.write(json);
                writer.flush();
            };

    public  final AccessDeniedHandler accessDeniedHandler =
            (request, response, accessDeniedException) -> {
                ErrorResponse fail = new ErrorResponse(HttpStatus.FORBIDDEN, "Spring security forbidden...");
                response.setStatus(HttpStatus.FORBIDDEN.value());
                String json = new ObjectMapper().writeValueAsString(fail);
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                PrintWriter writer = response.getWriter();
                writer.write(json);
                writer.flush();
            };

    @Getter
    @RequiredArgsConstructor
    public class ErrorResponse {

        private final HttpStatus status;
        private final String message;
    }
}




제가 준비한 내용은 여기까지 입니다. 포스팅을 하다보니 두서없이 적어진 느낌이 있는 것 같습니다... 부족한 글이지만 읽어주셔서 감사합니다!! 혹시 틀린 내용이나 코드가 있을 경우 언제든지 말씀해주시면 감사하겠습니다!!

혹시 보고싶은 소스코드는 github에 올려놓았으니 참고 부탁드리겠습니다~!

profile
daily study

4개의 댓글

comment-user-thumbnail
2023년 8월 30일

좋은 글 감사합니다!

답글 달기
comment-user-thumbnail
2023년 12월 7일

잘 보고 갑니다!

답글 달기
comment-user-thumbnail
2023년 12월 18일

시큐리티 최신버전 설정 정보가 부족해서 어려웠는데 도움이 되었습니다 .감사합니다.

답글 달기
comment-user-thumbnail
2024년 4월 1일

혹시 권한부여할떄 hasRole("ROLE_ADMIN")이렇게 사용하면 안되던데 왜 그런지 아시나요??
그리고 ENUM클래스로 사용하면 권한부여가 되는 이유도 궁금합니다!

답글 달기