12/4 CSRF, security 심화

AI·2025년 12월 4일

개발자 도구 때문에 생기는 에러(well-known) 해결

package com.mycom.myapp.user.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class WellknownController {
	 @GetMapping("/.well-known/**")
	    public ResponseEntity<Void> ignoreWellKnown() {
	        return ResponseEntity.noContent().build(); // 204 No Content
	    }
}

login.html 추가후 사용
SecurityConfig

	@Bean
	SecurityFilterChain fiterChain(HttpSecurity http) throws Exception{
		return http
				.authorizeHttpRequests(request -> 
					request.requestMatchers(
							"/","index.html",
							"/.well-known/**",
							"/login",
							"/csrf-token"
					).permitAll()
					.requestMatchers("/customer/**").hasAnyRole("CUSTOMER","ADMIN") // role 기반 접근
					.requestMatchers("/admin/**").hasRole("ADMIN")
					.anyRequest().authenticated()
				)
				// #1. csrf off
//				.csrf(csrf -> csrf.disable()) 
				// #2 csrf on (기본 적용)
				.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
				.formLogin(
						form -> form
						.loginPage("/login.html") // login.html 설정
						.loginProcessingUrl("/login") // spring security의 기본 login(post)으로 설정
						.defaultSuccessUrl("/")
						.permitAll()
				)
				.logout(logout->logout.permitAll())
				.build();
	}

CSRF(Cross Site Request Forgery)

로그인 후, 추가인증하는 역할

<!DOCTYPE html>
<html>
<head>
<meta charset="EUC-KR">
<title>index.html</title>
</head>
<body>
	<h1>login.html</h1>
	<hr>
<!-- 로그인 시점에 발생되는 csrf token 미리 발급 받아서 로그인과 동시에 전송
	 백엔드 csrf Controller에 요청
	 -->
	<form action="/login" method="post">
		<input type="text" id="username" name="username" value="dskim"><hr>
		<input type="password" id="password" name="password" value="1234"><hr>
<!-- csrf token -->
		<input type="hidden" id="_csrf" name="_csrf" value=""><hr>
		<button type="submit">login</button>
	</form>
<script>
	window.onload = function(){
		getCsrfToken();
	}
	
	// csrf controller에 tokern 요청
	async function getCsrfToken(){
		let response = await fetch("/csrf-token", 
				{method:"get", credentials:"same-origin"}
		);
		console.log(response);
		let data = await response.json();
		console.log(data);
		document.querySelector("#_csrf").value = data.token;
		
	}
</script>
</body>
</html>
<!-- csrf 설정 후 받은 token을 전송해야 함 -->
package com.mycom.myapp.user.controller;

import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CSRFContoller {
	// securityConfig 설정에 추가
	@GetMapping("/csrf-token")
	public CsrfToken csrf(CsrfToken token) {
		return token;
	}
}

요청해서 바로 넣는다고 하는데 작동 순서가 어떻게 되는거지? 그게 가능한가? 사진 선택하는 방식도 있던데 그럼, 로그인 정보를 계속 가지고 있다가 통과하면 내용을 같이 서버로 보내는 건가? / 컨트롤러들은 사전 순으로 실행이 되는건가? 그럼 실행되면서 에러가 발생할 수도 있겠네?
=>
로그인 페이지를 접속함과 동시에 csrf 토큰 생성되고 쿠키에 저장됨 => 로그인 버튼을 누르면 같이 보냄 => csrf 토큰을 먼저 검사하고 동일하면 아이디/비번 검사

  • 캡차 인증은 아이디/비번과 동일한 페이지
  • 1개의 요청(URL)에는 오직 1개의 컨트롤러(메서드)만 실행. 서비스가 여러개

====

4번

1. 자체 login.html 비동기 요청으로 처리
	- 로그인을 비동기 요청으로 처리한다는 것은 fetch를 이용해서 로그인 처리
	- 이에 대응해서 seurityConfig의 로그인 성공, 실패에 대한 처리자를 직접 작성, 전달.
      로그인 성공 url 은 주석
    - MyAuthenticationSuccessHandler, MyAuthenticationFailureHandler 작성 후, 
        (response 에서 응답코드, 결과 json 응답 처리)
     SecurityConfig 의 filterChain() 에 파라미터로 추가하고, 
     formLogin 의 successHandler(), successHandler, failureHandler 에 연결.	
   - MyUserDetailsService 는 변화 X
   - 로그인 성공은 /로 이동, 실패는 에러 메시지 표시
   
2. 회원 가입 기능 추가
	- 회원가입 기본 코드 전에 하던 프로젝트에서 복사 후 수정
		  userDto, userEntity 어노테이션 추가
		  user service, serviceImpl 수정
		  - "CUSTOMER" role을 기본 사용자 role
		  		DB로부터 가져와서 User 연결
		  - 파리미터 User -> UserDto
		  - passwordEncoder DI => build 할 때, 비번 암호화를 위해
		  => userController도 파라미터 UserDto로 변경
		     	@PostMapping("")
	  - register.html
	  		username -> name
	  		email 추가
	  		비동기로 /users post 요청

3. pageController 추가
4. board.html 추가
	인증 필요, 권한과는 무관
5. index.html 링크 추가

login.html에 추가

<script>
  ...
	// login
	async function login(){
		let url = "/login";
		
		let username = document.querySelector("#username").value;
		let password = document.querySelector("#password").value;
		let _csrf = document.querySelector("#_csrf").value;
		
		let urlParams = new URLSearchParams({
			username, password, _csrf
		});
		
		let fetchOptions = {
				method: "post",
				body: urlParams
		}
		let response = await fetch(url, fetchOptions);
		let data = await response.json();
		console.log(data);
		
		//
		if(data.result == "success"){
			window.location.href = "/";
		} else{
			document.querySelector("#errorMessage").innerText = "username 또는 password가 올바르지 않습니다.";
		}
	}
</script>

MyAuthenticationSuccessHandler, MyAuthenticationFailureHandler

package com.mycom.myapp.config;

import java.io.IOException;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

// 비동기 로그인 요청에 대해서 성공했을 때 전달되는 핸들러
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

	@Override
	public void onAuthenticationSuccess(
			HttpServletRequest request, 
			HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException {
		// response로 성공, json 응답
		response.setStatus(HttpServletResponse.SC_OK);
		response.setContentType("application/json");
		
		String jsonStr = """
				{"result":"success"}
				""";
		response.getWriter().write(jsonStr);
		
	}
}

Failure는 상속, status, jsonStr 값만 변경해주면 됨

SecurityConfig 수정

  • filterChain()
  • formLogin()
@Configuration
public class SecurityConfig {
	
	// passwordEncoder DI
	@Bean
	PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	// #1 well-known + login.html
	@Bean
	SecurityFilterChain fiterChain(
			HttpSecurity http,
			MyAuthenticationSuccessHandler successHandler,
			MyAuthenticationFailureHandler failureHandler 
			) throws Exception{
		return http
				.authorizeHttpRequests(request -> 
					request.requestMatchers(
							"/","index.html",
							"/.well-known/**",
							"/login", // login.html은 formLogin에서 권한 설정
							"/register","/register.html", "/users/**",
							"/csrf-token"
					).permitAll()
					.requestMatchers("/customer/**").hasAnyRole("CUSTOMER","ADMIN") // role 기반 접근
					.requestMatchers("/admin/**").hasRole("ADMIN")
					.anyRequest().authenticated()
				)
				.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
				.formLogin(
						form -> form
						.loginPage("/login.html") // login.html 설정
						.loginProcessingUrl("/login") // spring security의 기본 login(post)으로 설정
						// 비동기 요청처리는 client가 결과에 따라 페이지 또는 데이터 처리
						// 백엔드가 결정(redirect)
//						.defaultSuccessUrl("/") 
						.successHandler(successHandler) // 로그인 성공 시 처리자
						.failureHandler(failureHandler) // 로그인 실패 시 처리자
						.permitAll()
				)
				.logout(logout->logout.permitAll())
				.build();
	}
}

회원가입

userDto, userEntity에 추가

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor

user service 수정
User user -> UserDto userDto

user serviceImpl 수정

  • role CUSTOMER로 설정

  • UserDto userDto로 설정

  • passwordEncoder DI

  • 여러개의 role이 가능하기에 list로 받아서 userRoles로 정의

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

	private final UserRepository userRepository;
	private final UserRoleRepository userRoleRepository;
	// 패스워드 암호화를 위해 
	private final PasswordEncoder passwordEncoder;
	
	@Override
	@Transactional 
	public UserResultDto insertUser(UserDto userDto) {
		UserResultDto userResultDto = new UserResultDto();
		
		try {
			// 사용자 등록 시, "CUSTIMER"를 기본 Role로 처리
			// 새로운 Role 아닌 DB에서 가져와서 신규 사용자와 연결
			List<UserRole> userRoles = List.of(userRoleRepository.findByName("CUSTOMER"));
			
			User user = User.builder() // 영속화 x
							.name(userDto.getName())
							.email(userDto.getEmail())
							.password(passwordEncoder.encode(userDto.getPassword()))
							.userRoles(userRoles) // userRoles는 findByName()을 통해 영속화
							.build();
			
			User savedUser = userRepository.save(user); 
			System.out.println(savedUser);

			userResultDto.setResult("success");
		} catch(Exception e) {
			e.printStackTrace();
			TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
			userResultDto.setResult("fail");
		}
		
		return userResultDto;
	}

}

UserController 수정

package ...

import ...

@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
	private final UserService userService;
	
	@PostMapping("") // post => /users
	public UserResultDto insertUser(UserDto userDto) { // 원래 userDto를 받아서 userService에 전달하는게 올바른 방법
		return userService.insertUser(userDto);
	}
}

pageController 추가

package com.mycom.myapp.user.controller;

import ...

@Controller
public class PageController {
	@GetMapping("/login")
	public String login() {
		return "/login.html";
	}
	
	@GetMapping("/register")
	public String register() {
		return "/register.html";
	}
	
	@GetMapping("/board")
	public String board() {
		return "/board.html";
	}
}

index.html에 링크 추가

<body>
	<h1>index.html</h1>
	<a href="/board">board</a>
	<a href="/login">login</a>
	<a href="/register">register</a>
</body>

====

5번

1. MyUserDetailService 수정
	- dskim/1234(하드코딩) 인증 => user table access 하도록 변경
	- UserRepository 추가
	  userRepository의 findByEmail() 을 통해 이메일 기준 로그인
	  User UserRole 연관관계 설정 -> @OneToMany(fetch=FetchType.EAGER)
	  GrantedAuthority 대신 Role의 이름만으로 String[] security User의 빌딩 과정에서 roles() 전달
	  UserDetails 객체를 리턴
	  비밀번호 검증은 Spring Security가 UserDetails 객체와 사용자 입력으로 비교

MyUserDetailService

  • optionalUser로 DB Access 처리

  • spring security의 User와 혼동하지 않도록 주의

  • 영속화하는 이유(User user = optionalUser.get();) => JPA의 고급 기능을 사용하기 위해

  • @OneToMany(fetch=FetchType.EAGER) => user.getUserRoles()를 호출하는 시점에는 이미 DB에서 데이터를 다 가져와서 메모리에 있는 상태

  • new String[list.size()]로 사용하는게 아니라 new String[0] (빈 배열)을 던져주고, 내부에서 최적화된 새 배열을 만들게 하는 것이 속도도 더 빠르고 코드도 깔끔

package com.mycom.myapp.config;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

//import org.springframework.security.core.userdetails.User;
import ...

// entity user와 springSecurity user 클래스 이름 동일 => 주의
@Service 
@RequiredArgsConstructor 
public class MyUserDetailsService implements UserDetailsService {

	private final PasswordEncoder passwordEncoder;
	
	private final UserRepository userRepository;
	// 로그인 판단하는 건 loadUserByUsername()이 올바른 UserDetails 객체를 리턴 or 예외
	// DB Access로 변경
	@Override
	public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
		
		Optional<User> optionalUser = userRepository.findByEmail(email);
		
		if(optionalUser.isPresent()) {
			User user = optionalUser.get(); // 영속화
			// @OneToMany(fetch=FetchType.EAGER)
			List<UserRole> listUserRole = user.getUserRoles();
			
			// UserDeatils 객체 리턴
			// pw -> spring security가 리턴하는 userDetails객체의 pw와 로그인 시점에 전달한 pw 비교해서 처리
			// listUserRole에 포함된 userRole 각각의 role 이름을 String 만들어서 security의 user 객체에 전달
			List<String> roleStrList = new ArrayList<>();
			listUserRole.forEach(userRole -> roleStrList.add(userRole.getName()));
			String[] roleStrArray = roleStrList.toArray(new String[0]);
			
			return org.springframework.security.core.userdetails.User.builder()
					.username(user.getEmail())
					.password(user.getPassword())
					.roles(roleStrArray) // Sting[]로 권한의 이름들만 전달하지만, 내부적으로 GrantedAuthority로 변환 처리 (ROLE_prefix)
					.build();
		}else {
			throw new UsernameNotFoundException("user not found");
		}
	}

}

===

6번

1. MyUserDetails 추가
	- security User를 사용하지 않고 우리만의 UserDetails 클래스를 정의해서 더 다양한 필드 관리

UserDetails를 상속받은 MyUserDetails

package com.mycom.myapp.config;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import lombok.Builder;
import lombok.Getter;

@Builder
@Getter
public class MyUserDetails implements UserDetails{

	private static final long serialVersionUID = 1L;
	
	private final String username;
	private final String password;
	
	private final Collection<? extends GrantedAuthority> authorities;
	
	private final Long id;
    private final String name;
	private final String email;

	// @Getter로 햐결
//	@Override
//	public Collection<? extends GrantedAuthority> getAuthorities() {
//		// TODO Auto-generated method stub
//		return null;
//	}
//
//	@Override
//	public String getPassword() {
//		// TODO Auto-generated method stub
//		return null;
//	}
//
//	@Override
//	public String getUsername() {
//		// TODO Auto-generated method stub
//		return null;
//	}

}

MyUserDetailsService 수정

package com.mycom.myapp.config;

import ...

@Service 
@RequiredArgsConstructor 
public class MyUserDetailsService implements UserDetailsService {

	private final PasswordEncoder passwordEncoder;
	
	private final UserRepository userRepository;

	@Override
	public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
		
		Optional<User> optionalUser = userRepository.findByEmail(email);
		
		if(optionalUser.isPresent()) {
			User user = optionalUser.get();
			List<UserRole> listUserRole = user.getUserRoles();
			
			// 이전 코드에서 security의 User 객체를 이용할 때는 Role 이름민으로 문자열 배열로 전달. 내부적으로 GrantedAuthority 전환
			// MyUserDetails 객쳋 사용을 위해 직접 변환 처리
			List<SimpleGrantedAuthority> authorities = 
					listUserRole.stream()
						.map(UserRole::getName)
						.map(name -> "ROLE_"+ name) // Role의 이름에 "ROLE_" prefix 붙은 문자열
						.map(SimpleGrantedAuthority::new)
						.toList();
			return MyUserDetails.builder()
					.username(user.getEmail()) // 로그인시 email로 하기에
					.password(user.getPassword())
					.authorities(authorities) // roles() 대신 직접 GrantedAuthority에 authorities 필드 적용
					// 추가 정보
					.id(user.getId())
					.name(user.getName())
					.email(user.getEmail())
					.build();
		}else {
			throw new UsernameNotFoundException("user not found");
		}
	}
}

UserController에 사용자 정보 조회하는 함수 추가

	@GetMapping("/info")
	public UserDto infoUser(@AuthenticationPrincipal MyUserDetails userDetails) {
		return UserDto.builder()
				.name(userDetails.getUsername()) // 추후 개선 사항 email -> 이름 변경
				.id(userDetails.getId())
				.name(userDetails.getName())
				.email(userDetails.getEmail())
				.roles(userDetails.getAuthorities().stream().map(authority -> authority.getAuthority()).toList())
				.build();
	}

요약

개발자 도구 이슈 -> /.well-known/**

CSRF (Cross-Site Request Forgery)
1. 로그인 페이지 접속(GET) 시 서버가 토큰 발급.
2. 로그인 버튼 클릭(POST) 시 **[아이디+비번+토큰]**을 한 번에 전송.
3. 서버는 토큰이 유효한지 먼저 검사 후 로그인 처리.

비동기 로그인 구현 (Ajax + Security)
SuccessHandler / FailureHandler를 커스텀 구현 
=> formLogin()을 페이지 이동 대신 
   JSON ({"result":"success"})을 응답하도록 변경 
=> 프론트는 JSON을 보고 
   window.location.href로 페이지를 이동시키거나 에러 메시지
   
회원가입
- userRoles로 DB에 있는 Role과 연관 관계 설정
- 객체 생성

DB 연동 로그인
- MyUserDetailService 수정

커스텀 UserDetails
- 원하는 필드 추가
- hasRole() 메서드때문에 role에는 ROLE_ prefix 추가

0개의 댓글