241226 에이콘 아카데미 수업에 기반하여 작성되었음을 알립니다.
시큐리티 아키텍쳐를 꼭 그릴 수 있어야하고 이해 해야한다.
JWT
이전 글과 이어서 진행됩니다.
JwtAuthenticationFilter
// JwtAuthenticationFilter: JWT 인증을 처리하는 필터, 요청이 들어올 때 마다 실행
// 요청의 헤더 또는 쿠키에서 JWT 추출
@Component
//@RequiredArgsConstructor
//필요한 생성자를 자동 생성해주는 롬복 어노테이션, 사용 시 아래처럼 기본 생성자를 굳이 만들지 않아도 괜찮다.
public class JwtAuthenticationFilter extends OncePerRequestFilter {
// OncePerRequestFilter: 추상 클래스, HTTP 요청 당 한번씩 실행되는 필터
// 동일한 요청 내에서 중복 호출 방지 목적 (불필요한 작업을 방지한다.)
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
// 인젝션 과정에서 가장 안정적인 방법이 생성자 주입 방법이다. 사용 권장
public JwtAuthenticationFilter(JwtUtil jwtUtil,UserDetailsService userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// doFilterInternal: 필터의 핵심로직을 작성하는 부분
// 토큰 추출
String token = null;
String authHeader = request.getHeader("Authorization");
System.out.println("authHeader : " + authHeader);
// 토큰을 얻는 방법 두 가지: 다양한 클라이언트 환경 및 요청에 대해 유연한 대처가 필요하다.
if(authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
// Authorization: Bearer <JWT_TOKEN>에서 Bearer 접두어를 제거하고 실제 토큰만 추출한다.
} else {
// 쿠키에서 토큰 얻기
Cookie[] cookies = request.getCookies();
if(cookies != null) {
for(Cookie cookie : cookies) {
if("JWT".equals(cookie.getName())) {
token = cookie.getValue();
}
}
}
}
// 토큰 검증 및 인증
if(token != null && jwtUtil.vaildateToken(token)) {
// 토큰 주체 객체 생성
String sabun = jwtUtil.extractUsername(token);
// 사용자 인증 정보 객체 생성
UserDetails userDetails = userDetailsService.loadUserByUsername(sabun);
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
// 요청 처리 동안, 인증된 사용자 정보가 SecurityContext에 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// FilterChain: 여러 개의 필터가 순차적으로 실행되는 구조이다. 각 필터는 HTTP 요청 처리 후 다음 필터로 요청을 전달한다.
filterChain.doFilter(request, response); // JWT를 사용하며 STATELESS 정책을 쓰기 때문에 토큰 기반 인증을 간단히 처리할 수 있다.
}
}
💡 헤더 관련 참고
authHeader 값은 "Bearer " 는? Bearer 토큰이라는 인증 메커니즘을 사용할 때의 표준 방식이다.
HTTP Authorization 헤더에 토큰을 전달할 때, 보통 다음과 같은 형식을 사용한다.
Authorization: Bearer <JWT_TOKEN> Bearer: 토큰 타입을 명시한다.
Bearer는 "이 토큰을 가진 사람(베어러)이 인증된 사용자로 간주될 수 있다"는 의미이다. : 실제 JWT 토큰 값이다. RFC 6750에 따르면, Bearer 토큰은 OAuth 2.0에서 주로 사용되며, 위와 같은 형식이 표준이다.
따라서 authHeader.startsWith("Bearer ")로 확인하는 것은 토큰의 올바른 형식을 검증하는 적절한 방식이다.
추가 참고: 만약 다른 방식(예: Token <JWT_TOKEN> 또는 커스텀 접두사)을 사용할 계획이라면, 클라이언트와 서버가 같은 규칙을 따르도록 설정해야 한다. 하지만 표준을 따르는 것이 상호운용성과 유지보수 측면에서 더 유리하다.
CustomUserDetailService
@Service
// 사용자 인증 시 사용자 정보를 로드
public class CustomUserDetailService implements UserDetailsService {
// UserDetailsService : 사용자 정보를 DB 또는 기타 저장소에서 로드 후 UserDetails 객체를 반환
private final JikwonRepository jikwonRepository;
private final PasswordEncoder passwordEncoder;
public CustomUserDetailService(JikwonRepository jikwonRepository, PasswordEncoder passwordEncoder) {
this.jikwonRepository = jikwonRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public UserDetails loadUserByUsername(String sabun) throws UsernameNotFoundException {
// AuthenticationManager에 의해 호출 당할 메소드, 사용자 인증을 위한 메소드이다.
Long jikwonNo = Long.parseLong(sabun);
Jikwon jikwon = jikwonRepository.findById(jikwonNo)
.orElseThrow(() -> new UsernameNotFoundException("해당 직원이 없어"));
return User.builder()
.username(jikwon.getJikwonname())
.password(passwordEncoder.encode(jikwon.getJikwonname()))
.build();
}
}
SecurityConfig
@Configuration
//@EnableWebSecurity
//이미 설정을 다 했기 때문에 굳이 명시해주지 않아도 괜찮다.
public class SecurityConfig {
private final JwtUtil jwtUtil;
public SecurityConfig(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
// JWT 사용 시 주요 사항
// 1) 세션 관리는 비활성화 : 세션 대신 STATELESS 보안을 사용한다.
// 2) JWT 필터 추가 : 요청마다 JWT 토큰을 확인하고 인증 정보를 설정하기 위한 커스텀 필터를 만든다.
// 3) 폼 기반 인증을 제거 : JWT 인증에서는 로그인 페이지보다는 REST API를 로그인 요청으로 처리하므로 formlogin 설정을 제거할 수 있다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
http
.csrf(csrf -> csrf.disable()) // csrf 비활성화 : JWT 헤더를 인증하기 때문에 csrf 공격을 받을 수가 없다.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/auth/logout", "/static/**").permitAll()
.requestMatchers("/auth/gugu", "/auth/guguresult", "/auth/success").authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션없이 JWT만으로만 인증 처리할 것이기에 명시적으로 정책 처리해주는 것이 일반적인 방법이다.
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
// AuthenticationManager는 인증 로직의 중심 역할을 하며, 요청을 처리한다.
// AuthenticationConfiguration은 Spring Security의 AuthenticationManager를 설정 및 관리하는 도우미 역할을 한다.
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
login.html
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>시큐리티(JWT)으로 로그인 연습</h2>
<form th:action="@{/auth/login}" method="post">
직원번호 : <input type="text" name="sabun"><br/>
직원명 : <input type="text" name="irum"><br/>
<input type="submit" value="확인">
</form>
<div th:if="${param.error}">
<p style="color: red">잘못된 정보로 로그인 실패</p>
</div>
</body>
</html>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
이전 JWt에서는 csrf를 신경쓸 필요가 없기에 Spring Security Session에서 사용했던 코드를 지워준다.
AuthController
@Controller
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
@GetMapping("/auth/login")
public String login() {
return "login";
}
@PostMapping("/auth/login")
public String performLogin(@RequestParam(name = "sabun")String sabun,
@RequestParam(name = "irum")String irum, Model model, HttpServletResponse response) {
try {
// 두 개의 매개변수를 기반으로 인증 토큰을 생성
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(sabun, irum);
// 인증 매니저로 인증 시도
// CustomUserDetailService의 loadUserByUsername()을 호출하여 사용자 정보를 얻음
Authentication authentication = authenticationManager.authenticate(token);
String jwt = jwtUtil.generateToken(sabun);
// 생성된 JWT를 쿠키에 저장
Cookie jwtCookie = new Cookie("JWT", jwt);
jwtCookie.setHttpOnly(true);
jwtCookie.setSecure(false); // HTTPS에서만 사용할 경우 true
jwtCookie.setPath("/");
jwtCookie.setMaxAge(60 * 60); // 1시간
response.addCookie(jwtCookie);
model.addAttribute("username", authentication.getName());
return "success";
} catch (AuthenticationException e) {
model.addAttribute("error", "로그인 실패");
return "login";
}
}
@GetMapping("/auth/success")
public String success(Model model, Authentication authentication) {
if(authentication != null && authentication.isAuthenticated()) {
model.addAttribute("username", authentication.getName());
return "success";
}
return "redirect:/auth/login";
}
@GetMapping("/auth/logout")
public String logout(HttpServletResponse response) {
// JWT 쿠키 제거
Cookie jwtCookie = new Cookie("JWT", null);
jwtCookie.setHttpOnly(true);
jwtCookie.setSecure(false);
jwtCookie.setPath("/");
jwtCookie.setMaxAge(0);
response.addCookie(jwtCookie);
// 로그아웃 시 SecurityContextHolder를 초기화
SecurityContextHolder.clearContext();
return "redirect:/auth/login";
}
@GetMapping("/auth/gugu")
public String gugu(Model model, Authentication authentication) {
if(authentication == null || !authentication.isAuthenticated()) {
return "redirect:/auth/login";
}
model.addAttribute("username", authentication.getName());
return "gugu";
}
@PostMapping("/auth/gugu")
public String guguResult(@RequestParam(name = "num")int num, Model model, Authentication authentication) {
if(authentication == null || !authentication.isAuthenticated()) {
return "redirect:/auth/login";
}
model.addAttribute("username", authentication.getName());
model.addAttribute("num", num);
return "guguresult";
}
}
로그아웃
Role을 사용한 인증 / 인가
application.properties
spring.application.name=sprsecurity05role_ex
spring.thymeleaf.cache=false
logging.level.org.springframework.security=DEBUG
HomeController
@Controller
public class HomeController {
@GetMapping("/")
public String home() {
return "index";
}
@GetMapping("/notice")
public String notice() {
return "notice";
}
@GetMapping("/play")
public String play() {
return "play";
}
}
index.html
(templates에 생성)
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>홈페이지</h2>
<!-- sec:, 타임리프에서 제공하는 시큐리티 관련 속성 -->
<div sec:authorize="isAuthenticated()">
<b sec:authentication="name"></b>님 로그인중
<br/>
<a th:href="@{/user/logout}">로그아웃</a>
</div>
<div sec:authorize="!isAuthenticated()">
로그인 하지 않음
<br/>
<a th:href="@{/user/loginform}">로그인</a>
</div>
<ul>
<li><a th:href="@{/notice}">공지사항 (로그인 필요없음)</a></li>
<li><a th:href="@{/play}">놀이공간 (로그인 필요)</a></li>
<li><a th:href="@{/shop}">쇼핑하기 (로그인 필요없음), 상품 구입 시 (로그인 필요)</a></li>
<li><a th:href="@{/staff/user/list}">회원목록 (staff, admin 전용)</a></li>
<li><a th:href="@{/admin/user/manage}">회원관리 (admin 전용)</a></li>
</ul>
</body>
</html>
notice.html
(templates에 생성)
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h3>공지 사항(알림)</h3>
공지 사항 공지 사항 공지 사항 공지 사항
<br/>
<a th:href="@{/}">메인으로</a>
</body>
</html>
play.html
(templates에 생성)
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h3>놀이 공간</h3>
<strong sec:authentication="name"></strong>님 재밌게 놀아라!
<br/>
<a th:href="@{/}">메인으로</a>
</body>
</html>
UserController
@Controller
public class UserController {
@GetMapping("/staff/user/list")
public String userlist() {
return "user/list";
}
@GetMapping("/admin/user/manage")
public String usermanage() {
return "user/manage";
}
}
ShopController
@Controller
public class ShopController {
@GetMapping("/shop")
public String shop() {
return "shop";
}
}
shop.html
(templates에 생성)
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>이것저것 쇼핑몰</h2>
<p>핸드크림</p>
<form th:action="@{/shop/buy}">
<input type="hidden" name="goodscode" value="ks_cream100" />
<select name="amount">
<option>1</option>
<option>2</option>
<option>3</option>
</select>개
<button type="submit">구입하기</button>
</form>
</body>
</html>
ShopController
@Controller
public class ShopController {
... 생략
@GetMapping("/shop/buy")
public String buy(@RequestParam(name = "goodscode") String goodscode,
@RequestParam(name = "amount")int amount, Model model) {
String msg = goodscode + "번 상품" + amount + "개 주문 완료";
model.addAttribute("msg", msg);
return "buy";
}
}
buy.html
(templates에 생성)
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h3>주문 정보</h3>
<strong sec:authentication="name" />님께서 주문하신 내역입니다.
<br/>
[[${msg}]]
</body>
</html>
templates에 user 디렉토리 생성, 인증과 관련된 페이지를 넣어줌
list.html
(templates/ user 에 생성)
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h3>관리자(staff, admin) 페이지</h3>
<hr/>
<h2>회원목록</h2>
<pre>
염정섭
이건영
박시열
</pre>
<a th:href="@{/}">메인으로</a>
</body>
</html>
manage.html
(templates/ user 에 생성)
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h3>절대 관리자(admin) 전용 페이지</h3>
<hr/>
<h2>관리자로서 작업할 수 있는 화면</h2>
<a th:href="@{/}">메인으로</a>
</body>
</html>
현재 권한 부여가 되어있지않아 아무 페이지나 다 들어가진다.
MyUser
(엔티티라고 가정)
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MyUser {
private int id;
private String password;
private String userName;
private String email; // 기타 다양한 변수 선언 가능
private String role; // Authority 정보를 저장할 컬럼 ROLE_USER, ... 접두어(ROLE_)를 포함한 형식으로 저장
}
loginform.html
(templates/ user 에 생성)
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h3>사용자 정보 예</h3>
<table border="1">
<tr>
<th>id</th><th>password</th><th>role</th>
</tr>
<tr>
<td>guest</td><td>1234</td><td>ROLE_USER</td>
</tr>
<tr>
<td>batman</td><td>1234</td><td>ROLE_STAFF</td>
</tr>
<tr>
<td>superman</td><td>1234</td><td>ROLE_ADMIN</td>
</tr>
</table>
<h3>로그인</h3>
<form th:action="@{/user/login}" method="post">
사용자명 : <input type="text" name="userName" /><br/>
비밀번호 : <input type="password" name="password" /><br/>
<button type="submit">로그인</button>
</form>
</body>
</html>
다음 시간에 이어서!
'Study > Acorn' 카테고리의 다른 글
241230 도커 (설치, 기본명령어) (0) | 2024.12.30 |
---|---|
241227 인증/인가(Role) (0) | 2024.12.27 |
241224 인증 / 인가 (권한, Security, JWT) (1) | 2024.12.24 |
241223 인증 / 인가 (0) | 2024.12.23 |
241220 인증 / 인가 (0) | 2024.12.20 |