안녕하세요. J4J입니다.
이번 포스팅은 spring security를 이용하여 인증/인가 처리하기 두 번째인 mvc와 session에 활용하는 방법에 대해 적어보는 시간을 가져보려고 합니다.
이전 글
[SpringBoot] Spring Security를 이용하여 인증/인가 처리하기 (1) - 개념 및 기본 설정
들어가기에 앞서
해당 글에서 사용되는 spring boot 버전은 다음과 같습니다.
- boot → 3.2.2
- java → 17
MVC와 Session을 이용한 설정
이전 글에서 spring security에 대한 설명과 사용되는 인증 처리 방식에 대한 소개를 드렸습니다.
그중 하나인 session을 이용하여 spring security를 적용하는 방법에 대해 작성해보려고 하며, session은 thymeleaf를 이용한 mvc 구조와 함께 표현하려고 합니다.
로그인 페이지도 기본적으로 제공해 주는 것을 사용하지 않고 그 외 부가적인 설정들을 포함하여 인증/인가 처리된 사용자만 접근할 수 있는 다양한 페이지를 구성해 보겠습니다.
[ 1. dependency 추가 ]
// build.gradle
dependencies {
// spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
// thymeleaf
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
}
[ 2. 인증/인가 exception handling 클래스 추가 ]
spring security를 사용하여 인증/인가 처리에 exception이 발생될 경우 handling 할 수 있는 클래스를 설정할 수 있습니다.
필요 없으신 분들도 있겠지만 보통 발생된 exception에 대한 handling을 많이들 하시기 때문에 간단하게 설정해 봤습니다.
// 인증 exception handling
package com.jforj.springsecuritysession.exception;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setCharacterEncoding("utf-8"); // response encoding 설정
response.setStatus(HttpStatus.UNAUTHORIZED.value()); // response http status 설정
response.setContentType(MediaType.APPLICATION_JSON_VALUE); // response content-type 설정
response.getWriter().write("인증에 실패했습니다."); // response data 설정
}
}
// 인가 exception handling
package com.jforj.springsecuritysession.exception;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class SecurityAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setCharacterEncoding("utf-8"); // response encoding 설정
response.setStatus(HttpStatus.UNAUTHORIZED.value()); // response http status 설정
response.setContentType(MediaType.APPLICATION_JSON_VALUE); // response content-type 설정
response.getWriter().write("권한이 존재하지 않습니다."); // response data 설정
}
}
[ 3. spring security 설정 클래스 추가 ]
package com.jforj.springsecuritysession.config;
import com.jforj.springsecuritysession.exception.SecurityAccessDeniedHandler;
import com.jforj.springsecuritysession.exception.SecurityAuthenticationEntryPoint;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final SecurityAuthenticationEntryPoint securityAuthenticationEntryPoint;
private final SecurityAccessDeniedHandler securityAccessDeniedHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.httpBasic(Customizer.withDefaults()) // 기본적인 security 설정 적용
.csrf(AbstractHttpConfigurer::disable) // csrf 설정은 사용하지 않음
.sessionManagement(Customizer.withDefaults()) // 기본적인 session 관리 설정 적용
.authorizeHttpRequests(
authorizeHttpRequests ->
authorizeHttpRequests
// /page/login 페이지와 /page/logout 페이지는 모든 사용자가 접근 가능
.requestMatchers("/page/login", "/page/logout").permitAll()
// /page/admin 페이지는 ROLE_ADMIN 권한이 있는 사용자만 접근 가능
.requestMatchers("/page/admin").hasAuthority("ROLE_ADMIN")
// /page/user 페이지는 ROLE_ADMIN과 ROLE_USER 권한이 있는 사용자만 접근 가능
// hasRole, hasAnyRole은 "ROLE_"을 prefix로 사용하지 않음
.requestMatchers("/page/user").hasAnyRole("ADMIN", "USER")
// 그 외 모든 요청에 대해서 인증된 사용자만 접근 가능
.anyRequest().authenticated()
)
.formLogin(
formLogin ->
formLogin
// 로그인 페이지 URL 설정
.loginPage("/page/login")
// form action 처리가 이루어질 때 username이 담겨야 하는 parameter name (ex, <input type="text" name="id" />)
.usernameParameter("id")
// form action 처리가 이루어질 때 password가 담겨야 하는 parameter name (ex, <input type="password" name="password" />)
.passwordParameter("password")
// security에 의해 로그인 프로세스가 자동으로 이루어지는 URL (form action 경로에 사용)
// controller에서 따로 구현 필요 없음
.loginProcessingUrl("/login")
// 로그인이 이루어질 때 이동되는 URL 설정
.defaultSuccessUrl("/page/user")
)
.logout(
logout ->
logout
// security에 의해 로그아웃 프로세스가 자동으로 이루어지는 URL (form action 경로에 사용)
// controller에서 따로 구현 필요 없음
.logoutUrl("/logout")
// 로그아웃이 이루어질 때 이동되는 URL 설정
.logoutSuccessUrl("/page/logout")
// 로그아웃 이후 세션 초기화 설정
.invalidateHttpSession(true)
// 로그아웃 이후 쿠키 삭제 설정
.deleteCookies("JSESSIONID")
)
.exceptionHandling(
exceptionHandling ->
exceptionHandling
// 인증 문제가 발생될 경우 exception handling
.authenticationEntryPoint(securityAuthenticationEntryPoint)
// 인가 문제가 발생될 경우 exception handling
.accessDeniedHandler(securityAccessDeniedHandler)
)
;
return httpSecurity.build();
}
}
[ 4. userDetailService 클래스 추가 ]
userDetailService는 spring security에서 제공해 주는 인터페이스로 사용자 정보 및 권한을 설정하여 security에게 공유해 주는 구간으로 이해해 주시면 됩니다.
일반적으로 userDetailService를 통해 이루어지는 flow는 다음과 같습니다.
- 로그인 요청을 통해 전달받은 사용자 아이디를 확인
- 사용자 아이디를 이용하여 데이터베이스 및 외부 API를 활용하여 사용자 정보 및 권한 조회
- 조회된 데이터를 이용하여 Authentication 객체 생성 (=UserDetails)
- Authentication 정보와 로그인 정보가 동일한지 확인
- Authentication 정보를 SecurityContext에 보관
이런 과정을 통해 SecurityContext에 인증된 사용자 정보가 보관되면 spring security는 SecurityContextHolder에 의해 사용자 요청이 넘어올 때마다 session 정보를 확인하여 올바른 사용자 인지를 확인하고 보관되어 있는 사용자 정보를 제공해 줄 수 있습니다.
즉, 객체만 등록해 주면 다른 설정을 하지 않더라도 SecurityConfig 클래스에 넣어둔 인증/인가 처리를 자동으로 제공해 줍니다.
package com.jforj.springsecuritysession.service;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class SessionUserDetailService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
// *** DB or 외부 API로부터 사용자 정보 및 권한 정보를 조회하는 구간 ***
// *** 해당 코드에서는 구현하지 않기 때문에 정보를 조회하지 않고 더미 데이터를 활용 ***
String userId = username;
String userPassword = "password";
String[] userRoles = new String[]{};
switch (username) {
// 로그인한 사용자가 admin인 경우 ROLE_ADMIN, ROLE_USER 권한 부여
case "admin": {
userRoles = new String[]{"ADMIN", "USER"};
break;
}
// 로그인한 사용자가 user인 경우 ROLE_USER 권한 부여
case "user": {
userRoles = new String[]{"USER"};
break;
}
}
// userDetails는 필요한 경우 커스텀하여 사용할 수 있음
UserDetails userDetails =
User
.withUsername(userId)
.password(passwordEncoder.encode(userPassword))
.roles(userRoles)
.build();
return userDetails;
}
}
[ 5. thymeleaf 설정을 통한 페이지 구성 ]
// controller
package com.jforj.springsecuritysession.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/page")
public class PageController {
@GetMapping("/login")
public String login() {
return "login";
}
@GetMapping("/admin")
public String admin() {
return "admin";
}
@GetMapping("/user")
public String user() {
return "user";
}
@GetMapping("/logout")
public String logout() {
return "logout";
}
}
// main/resources/templates/login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login</title>
</head>
<body>
<form method="post" action="/login">
<h2>Login Page</h2>
<input type="text" name="id" placeholder="아이디를 입력해주세요."/>
<input type="password" name="password" placeholder="비밀번호를 입력해주세요"/>
<button>Login</button>
</form>
</body>
</html>
// main/resources/templates/admin.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Admin</title>
</head>
<body>
<form method="post" action="/logout">
<h2>Admin Page</h2>
<button>Logout</button>
</form>
</body>
</html>
// main/resources/templates/user.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>User</title>
</head>
<body>
<form method="post" action="/logout">
<h2>User Page</h2>
<button>Logout</button>
</form>
</body>
</html>
// main/resources/templates/logout.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Logout</title>
</head>
<body>
<div>
<h2>Logout Page</h2>
</div>
</body>
</html>
테스트 (1) - user
위와 같이 설정이 모두 완료되었다면 테스트를 해보겠습니다.
서버를 실행한 뒤 다음과 같이 /page/login에 접속해 보면 로그인 정보를 입력할 수 있는 화면을 확인할 수 있습니다.
userDetailService에 작성해 둔 것을 확인해 보면 사용자 아이디가 "user"인 경우 ROLE_USER 권한을 설정하는 것을 볼 수 있습니다.
또한 비밀번호도 "password"라고 입력되어야만 정상적으로 로그인되는 것도 확인할 수 있습니다.
그러므로 로그인 페이지에서 아이디에는 "user"를 입력하고 비밀번호에는 "password"를 입력한 뒤 로그인 버튼을 클릭하면 다음과 같이 /page/user로 redirect 되는 것을 볼 수 있습니다.
여기서 권한이 부여되어 있지 않은 /page/admin를 url에 강제 입력해 보겠습니다.
그러면 권한이 존재하지 않기 때문에 exception handling 클래스에 넣어둔 대로 페이지가 보이는 것을 확인할 수 있습니다.
테스트 (2) - admin
이번에는 admin 아이디를 이용하여 테스트해보겠습니다.
admin 아이디는 userDetailService에 정의해 둔 대로 ROLE_ADMIN과 ROLE_USER 권한이 부여되는 계정입니다.
또한 비밀번호는 user와 동일하게 "password"를 사용하고 있습니다.
정보를 모두 확인했다면 이번엔 /page/login에 접속하겠습니다.
다음과 같이 아이디에는 "admin"을 입력하고 비밀번호에는 "password"를 입력한 뒤 로그인 버튼을 클릭하면 이 또한 /page/user로 redirect 되는 것을 볼 수 있습니다.
이번에도 user에서는 접근할 수 없었던 /page/admin 페이지를 url에 강제 입력해 보겠습니다.
그러면 user와는 달리 해당 페이지에 대한 접속 권한이 있는 admin은 다음과 같이 접속이 올바르게 되는 것을 확인할 수 있습니다.
테스트 (3) - logout
user와 admin을 이용하여 테스트를 모두 해봤다면 logout 테스트를 해보겠습니다.
/page/user 또는 /page/admin에 보이는 로그아웃 버튼을 클릭해 보면 다음과 같이 /page/logout으로 redirect 되는 것을 볼 수 있습니다.
또한 로그인을 할 경우 다음과 같이 쿠키에서 확인할 수 있는 session ID도 로그아웃이 되면 설정한 대로 자동 삭제가 되는 것도 볼 수 있습니다.
이상으로 spring security를 이용하여 인증/인가 처리하기 두 번째인 mvc와 session에 활용하는 방법에 대해 간단하게 알아보는 시간이었습니다.
읽어주셔서 감사합니다.
'Spring > SpringBoot' 카테고리의 다른 글
[SpringBoot] Thread Local을 이용하여 Thread 별 독립적으로 변수 관리하기 (0) | 2024.05.04 |
---|---|
[SpringBoot] Spring Security를 이용하여 인증/인가 처리하기 (3) - API와 JWT Token에 활용하기 (0) | 2024.02.22 |
[SpringBoot] Spring Security를 이용하여 인증/인가 처리하기 (1) - 개념 및 기본 설정 (0) | 2024.02.13 |
[SpringBoot] properties에 담긴 환경 변수를 클래스로 사용하기 (1) | 2024.02.08 |
[SpringBoot] Layer별 테스트 코드 작성하기 (3) - Controller 테스트 (2) | 2024.02.07 |
댓글