SecurityFilterChain을 커스터마이징 한다면, 기본 로그인 창이 뜨지 않게 된다.
기본 로그인 창에서 기본 아이디인 admin과 콘솔창에 뜬 비번을 입력해서 로그인을 해 준다면, 일정 시간 동안은 localhost:8080 을 통해 재접속 하더라도 아이디, 비번을 요구하지 않고 바로 로그인 된다. 쿠기 란에서 보면 JSESSIONID=B0521FD75DEC7AA82F5B43EA323DCDCD
로 세션 방식 로그인이 적용되었음을 알 수 있음.
지금부터 SecurityFilterChain을 등록한다.
global에 config 패키지를 만든 뒤 WebSecurifyConfig 클래스를 생성한다. @Configuration과 @EnableWebSecurity 어노테이션을 붙여주고, SecurityFilterChain 객체인 filterChain의 매개변수에 HttpSecurity 객체인 http를 넘겨준다. HttpSecurity 빈은 HttpSecurityConfiguration 클래스에 정의되어있다. 이 객체는 싱글턴인데, 싱글턴 객체란 어디서 주입받던 동일한 인스턴스를 사용하는 객체이다. 즉, 애플리케이션 전체에서 단 하나만 존재하는 객체이다. 따라서 설정용 객체 HttpSecurity를 이용하여 SecurityFilterChain에서 싱글턴 빈으로 등록된다. HttpSecurity에서 보안 설정을 해줄 수 있다. 인증/인가 규칙도 설정하고, URL 접근 권한도 설정하고, 로그인 방식도 정의할 수 있다.
정리하면, HttpSecurity에서 내가 원하는 보안 설정을 해 준 다음에, 보안 설정을 마친 후 HttpSecurity객체 http를 SecurityFilterChain에 등록하여 운영하는 것이다. 반환은 http.build(); 로 진행한다.
엔티티 계층에 MemberRole Enum 클래스 생성
@Getter
@RequiredArgsConstructor
public enum MemberRole {
// ROLE 접두사를 붙인다.
REGULAR("ROLE_REGULAR"),
ADMIN("ROLE_ADMIN");
private final String value;
}
수정된 Member 클래스
@Enumerated(EnumType.STRING)
private MemberRole role;
public Member(String username, String password, MemberRole role) {
this.username = username;
this.password = password;
this.role = role;
}
이제 서비스 계층에서 회원가입 하는 User의 정보에 MemberRole.REGULAR를 추가로 넣어주면 된다.
filterChain
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 기본 폼 로그인 비활성화
.formLogin(formLogin -> formLogin.disable())
.httpBasic(httpBasic -> httpBasic.disable())
// csrf는 켜 주는 게 좋지만, 테스트 환경에서는 disable
.csrf(csrf -> csrf.disable())
// 세션 정의
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
// 멤버의 권한 정의
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/signup", "/auth/login").permitAll()
.requestMatchers("/crud/members/**").hasAnyRole("REGULAR")
.anyRequest().authenticated());
return http.build();
}
}
패스워드를 평문으로 저장하고 있으므로 암호화를 한다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// request를 record로 변경함.
String encoded = passwordEncoder.encode(request.password());
Member member = new Member(request.username(), encoded, MemberRole.REGULAR);

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
// DB에 유저가 존재하는지 확인, 있어야 인증해줌
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByUsername(username);
if (member == null) {
throw new UsernameNotFoundException(username);
}
return CustomUserDetails.from(member);
}
}
loadUserByUsername을 재정의한다. Spring Security가 loadUserBuUsername(username)을 호출한다. 그 후 memberRepository를 통해 DB에서 username에 맞는 member를 찾는다. 만약 없다면 UsernameNotFoundException을 반환한다. 찾았다면 CustomUserDetails와 from을 이용하여 객체를 반환한다.
@Getter
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final Long memberId;
private final String username;
private final String password;
private final MemberRole role;
// 생성자
public static CustomUserDetails from(Member member) {
return new CustomUserDetails(member.getId(), member.getUsername(), member.getPassword(), member.getRole());
}
// 판별 기준, 지금은 MemberRole에 따라 결정.
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(role.getValue()));
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
}
역시 UserDetails를 implements한다. 오버라이딩해야 할 메서드는 getAuthorities, getPassword, getUsername이 있다. 사용해야 할 정보들을 필드에 명시한다. memberId, username, password, role을 가져온다.
생성자에서 from() 메서드를 써먹어준다. DB의 Member 객체를 UserDetails 형태로 변환해준다.
getAuthorities()에서는 사용자의 권한 목록을 반환한다. 목록으로 설정한 이유는 지금은 두 개의 역할만 있지만, 실제로는 여러 가지의 역할을 한 사람이 가질 수 있기 때문이다. SimpleGrantedAuthority는 Spring Security에서 권한을 표현하는 기본 클래스이자 사용자의 권한 문자열을 담는 객체를 생성한다. 여기에서는 ROLE_REGULAR를 Spring에게 전달할 것이다. 즉, 권한 문자열을 SimpleGrantedAuthority로 감싸줘야 한다.
public record LoginRequest(String username, String password) {
}
// 내 정보 보기
@GetMapping("/me")
public ResponseEntity<MemberInfoResponse> getMyInfo(@AuthenticationPrincipal CustomUserDetails userDetails) {
Long memberId = userDetails.getMemberId();
return ResponseEntity.ok(memberService.getMyInfo(memberId));
}
@AuthenticationPrincipal 어노테이션이 Spring Security가 현재 로그인된 사용자 정보를 넘겨주도록 한다. 내부적으로 로그인 성공 시 CustromUserDetails가 SecurityContext에 저장된다. 여기서 memberId를 추출한 뒤, Service 계층을 이용해 DB에서 사용자 정보를 조회한다. 그 값을 return한다.
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final MemberService memberService;
private final AuthenticationManager authenticationManager;
@PostMapping("/signup")
public ResponseEntity<Void> signup(@RequestBody MemberCreateRequest request) {
memberService.createMember(request);
return ResponseEntity.ok().build();
}
@PostMapping("/login")
public ResponseEntity<Void> login(@RequestBody LoginRequest request, HttpServletRequest httpRequest) {
// 1. AuthenticationManager가 다룰 수 있도록 요청을 토큰 객체에 담는다.
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(request.username(), request.password());
// 2. AuthenticationManager가 객체를 통해 인증 시도. 실패 시 Exception
Authentication authentication = authenticationManager.authenticate(token);
// 3. 인증 성공 시 현재 인증정보 저장
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
// 4. 세션 생성, JSESSIONID 발급
HttpSession session = httpRequest.getSession(true);
// 5. 세션에 인증 정보 저장
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
return ResponseEntity.ok().build();
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
HttpSession session = httpRequest.getSession(false);
if (session != null) {
session.invalidate();
}
// 인증 정보 삭제
SecurityContextHolder.clearContext();
return ResponseEntity.ok().build();
}
}
{
"errorCodeName": "MEMBER_USERNAME_DUPLICATE",
"errorMessage": "이미 존재하는 유저네임입니다."
}
{
"memberId": 1,
"username": "ruru"
}