개발자 도구 때문에 생기는 에러(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();
}
로그인 후, 추가인증하는 역할
<!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. 자체 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 링크 추가
<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>
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 값만 변경해주면 됨
@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>
====
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 객체와 사용자 입력으로 비교
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");
}
}
}
===
1. MyUserDetails 추가
- security User를 사용하지 않고 우리만의 UserDetails 클래스를 정의해서 더 다양한 필드 관리
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;
// }
}
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");
}
}
}
@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 추가