Spring Security는 Spring 기반 애플리케이션의 보안(인증과 권한 등)을 담당하는 프레임 워크입니다. Spring Security는 인증과 권한에 대해 Filter 흐름에 따라 처리하고 있으며 보안과 관련해서 많은 옵션을 제공해주고 있기 때문에 개발자가 일일이 보안 로직을 작성하지 않아도 된다는 장점이 있습니다.
▷ 인증(Authorizatoin)과 인가(Authentication)
소스코드: github
Spring Security는 OAuth2(소셜 로그인), JWT(Json Web Token) 등 다양한 로그인에 대해 제공합니다. 해당 글에서는 이러한 방법 보다는 기본적인 설정을 통해 인증과 인가에 대해 알아보고자 합니다.
스프링부트 3.0 부터 스프링 시큐리티 6.0.0 이상의 버전이 적용되어있습니다. 해당 Security 버전에서는 antMatchers, WebSecurityConfigurerAdapter 등이 deprecated되어 쓰니는 Spring boot 3.1.0 버전으로 작성하였습니다.
▷ 사용 기술
▷ gradle(security 적용 전)
프로젝트를 생성하고 build.gradle의 dependencies를 다음과 같이 설정하였습니다.
▷ 메인 화면
▷ 사용자 화면
▷ 관리자 화면
▷ 로그인 및 회원가입 화면
현재 모든 화면은 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 설정을 하겠습니다.
코드를 간단히 설명하자면
참고로 @EnableWebSecurity는 모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 애너테이션으로 @EnableWebSecurity 애너테이션을 사용하면 내부적으로 SpringSecurityFilterChain이 동작하여 URL 필터가 적용됩니다.
이제 domain패키지 하위에 member 패키지를 생성하고 그 밑에 Role Enum을 만들어 줍니다.
하지만 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 설정을 할 수 있습니다.
위의 소스코드를 추가합니다.
<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>
@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 내용이 아니니 소스코드 소개하고 넘어가겠습니다.
<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>
@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();
}
}
...
@PostMapping("/login/join")
public String userJoin(@ModelAttribute MemberJoinRequestDto requestDto) {
memberService.addUser(requestDto);
return "login/login";
}
...
@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에 저장된 암호화된 비밀번호와 같은지 확인할 수 있습니다.
이제 회원가입할때에도 암호화를 할 수 있도록 코드를 수정하도록 하겠습니다.
@RequiredArgsConstructor
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
public String addUser(MemberJoinRequestDto requestDto) {
return memberRepository.save(requestDto.toEntity()).getUsername();
}
}
@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에 올려놓았으니 참고 부탁드리겠습니다~!
좋은 글 감사합니다!