241226 인증/인가 (JWT, 권한)

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