[Spring] 스프링 시큐리티를 이용한 로그인 구현

rekv·2025년 2월 6일

Spring

목록 보기
11/15

보안 용어

인증(Authentication)

사용자가 누구인지 확인하는 단계
인증의 대표적인 예로는 로그인이 있다. 로그인에 성공하면 애플리케이션 서버는 응답으로 사용자에게 토큰(token)을 전달하며, 로그인에 실패한 사용자는 토큰을 전달받지 못해 원하는 리소스에 접근할 수 없게 된다.

인가(Authorization)

인증을 통해 검증된 사용자가 애플리케이션 내부의 리소스에 접근할 때 사용자가 해당 리소스에 접근할 권리가 있는지를 확인하는 과정
예를 들어, 로그인한 사용자가 특정 게시판에 접근해서 글을 보려고 하는 경우 게시판 접근 등급을 확인해 접근을 허가하거나 거부하는 것
일반적으로 사용자가 인증 단계에서 발급받은 토큰은 인가 내용을 포함하고 있으며, 사용자가 리소스에 접근하면서 토큰을 함꼐 전달하면 애플리케이션 서버는 토큰을 통해 권한 유무 등을 확인해 인가를 수행한다.

스프링 시큐리티

스프링 시큐리티란?

애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 스프링 하위 프로젝트 중 하나

스프링 시큐리티는 서블릿 필터(Servlet Filter)를 기반으로 동작하며, DispatcherServlet 앞에 필터가 배치돼 있다.

필터 체인(FilterChain) : 서블릿 컨테이너에서 관리하는 ApplicationFilterChain, 클라이언트에서 애플리케이션으로 요청을 보내면 서블릿 컨테이너는 URI를 확인해서 필터와 서블릿을 매핑한다.

스프링 시큐리티는 사용하고자 하는 필터체인을 서블릿 컨테이너의 필터 사이에서 동작시키기 위해 DelegatingFilterProxy를 사용한다.
DelegatingFilterProxy는 서블릿 컨테이너의 생명주기와 스프링 애플리케이션 컨텍스트(Application Context) 사이에서 다리 역할을 수행하는 필터 구현체이다. 표준 서블릿 필터를 구현하고 있으며, 역할을 위임할 필터체인 프록시(FilterChainFroxy)를 내부에 가지고 있다. 필터체인 프록시는 스프링 부트의 자동 설정에 의해 자동 생성된다.

필터체인 프록시는 스프링 시큐리티에서 제공하는 필터로 보안 필터체인(SecurityFilterChain)을 통해 많은 보안 필터(Security Filter)를 사용할 수 있다. 필터체인 프록시에서 사용할 수 있는 보안 필터 체인은 List 형식으로 담을 수 있게 설정돼 있어 URI 패턴에 따라 특정 보안필터 체인을 선택해서 사용하게 된다.
보안필터 체인에서 사용하는 필터는 여러 종류가 있으며, 각 필터마다 실행되는 순서가 다르다.

보안 필터 체인은 WebSecurityConfigurerAdapter클래스를 상송받아 설정할 수 있다.

실습

별도의 설정이 없다면 스프링 시큐리티에서는 SecurityFilterChain에서 사용하는 필터 중 UsernamePasswordAuthenticationFilter를 통해 인증을 처리한다.

UsernamePasswordAuthenticationFilter를 통한 로그인 인증 수행 과정

  1. 클라이언트로부터 Http Request 요청을 받으면 서블릿 필터에서 HTTPServletRequest에 정보(로그인의 경우 아이디, 비밀번호)가 전달된다. 이때 UsernamePasswordAuthenticationFilter(위 그림에서는 AuthenticationFilter)가 유효성 검사르 실시해 인증을 처리한다.
  2. AuthenticationFilter는 요청 객체(HttpServletRequest)에서 Dto 객체(username과 password 추출)를 생성해서 토큰을 생성한다.
  3. 생성한 토큰을 AuthenticationManager에게 전달한다. AuthenticationManager에게는 인터페이스이며, 일반적으로 사용하는 구현체는 ProviderManager이다.
  4. ProviderManager는 인증을 위해 AuthenticationProvider로 토큰을 전달한다.
  5. AuthenticationProvider는 토큰의 정보를 UserDetails에게 전달한다.
    *DB를 통해 우리가 원하는 UserDetails 객체를 생성하기 위해 수정 필요
  6. UserDetailService는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아 UserDetails 객체를 생성한다.
  7. 생성된 UserDetails 객체는 AuthenticationProvider로 전달되며, 해당 Provider에서 인증을 수행한다.
    *우리가 생성한 UserDetails를 인증받기 위해 Override를 통해 수정 필요
  8. 인증에 성공하게 되면 ProviderManager로 권한을 담은 토큰을 전달한다.
  9. ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달한다.
  10. AuthenticationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장한다.
    *SecurityContext는 Session에 저장되지만, 우리는 JWT를 쓸 예정이므로 수정 필요

인증 과정 중 코드를 통해 수정이 필요한 부분은 *로 표시

build.gradle

build.gradleimplementation 'org.springframework.boot:spring-boot-starter-security'을 추가한다.

서버를 실행하면 따로 로그인 페이지를 만들지 않아도 생성된 것을 확인할 수 있다.

Username은 user이며, Password는 콘솔 창에 표시된다.

회원가입

User

//User.java
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@Entity
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long idx;
    private String email;
    private String password;
    private String role;

    @OneToMany(mappedBy = "user")
    private List<Feed> feedList = new ArrayList<>();


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        GrantedAuthority authority = new SimpleGrantedAuthority(role);
        authorities.add(authority);
        return authorities;
    }

    @Override
    public String getPassword() { return password; }

    @Override
    public String getUsername() { return email; } //username대신 email을 사용할 것이므로 getUsername의 return값을 email로 변경

    @Override
    public boolean isEnabled() { return true; } // 추후 이메일 인증 기능을 추가할 때는 false로 변경
}

스프링 시큐리티가 만드는 UserDetails 객체가 아닌, 우리가 원하는 UserDetails 객체를 생성하기 위해 UserDetails 상속

UserDetails는 interface이므로 메서드를 따로 구현해줘야 하는데, Ctrl+o를 누르면 재정의/구현할 메서드 목록이 뜬다. (일일이 작성할 필요 x)

UserDto

//UserDto.java
public class UserDto {

  @Getter
  public static class SignupRequest {
    private String email;
    private String password;

    public User toEntity(String encodedPassword) {
    //임시로 role을 'ROLE_USER'로 하드코딩, 추후 필요에 따라 유저마다 다른 ROLE 부여하도록 수정
      User user = new User(null, email, encodedPassword, "ROLE_USER");
      return user;
    }
  }

  @AllArgsConstructor
  @NoArgsConstructor
  @Getter
  public static class SignupResponse {
    private String email;
    private Long idx;

    public static SignupResponse from(User user) {
      return new SignupResponse(user.getEmail(), user.getIdx());
    }
  }
}

우선 회원 가입을 위한 SignupRequest와 SignupRequest를 작성

encodedPassword가 들어간 이유
password를 검증할 때, Spring Security 5.x 이상부터는 비밀번호를 반드시 인코딩해야 한다. 만약 코드가 평문(plain text)으로 저장되어 있거나, PasswordEncoder 설정이 누락되면 에러가 발생한다.

DB에 role을 저장할 때, 그냥 USER가 아닌 ROLE_USER로 저장하는 이유

UserService

UserService에서 UserServiceDetailsService를 상속받아 원하는 정보가 담긴 UserDto를 반납하도록 코드를 작성

//UserService.java
@Service
@RequiredArgsConstructor
public class UserService implements UserDetailsService {
  private final UserRepository userRepository;
  private final PasswordEncoder passwordEncoder;

  public UserDto.SignupResponse signup(UserDto.SignupRequest signupRequest) {
    User user = userRepository.save(signupRequest.toEntity(passwordEncoder.encode(signupRequest.getPassword())));
    return UserDto.SignupResponse.from(user);
  }
}

UserController

@RequiredArgsConstructor
@RestController
@RequestMapping("/user")
public class UserController {
    private final UserService userService;

    @PostMapping("/signup")
    public ResponseEntity<UserDto.SignupResponse> signup(
            @RequestBody UserDto.SignupRequest dto) {

        UserDto.SignupResponse response = userService.signup(dto);
        return ResponseEntity.ok(response);
    }
}

SecurityConfig

//SecurityConfig.java
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
  private final AuthenticationConfiguration configuration;

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
  @Bean
  public SecurityFilterChain configure(HttpSecurity http) throws Exception {
    // <script>alert("asd")</script>, 일반적으로 XSS
    // <script>게시글 작성 스크립트</script>, 일반적으로 CSRF
    // CSRF : 크로스 사이트 요청 변조
    http.csrf(AbstractHttpConfigurer::disable);
    //우리가 만든 로그인 로직(JWT)을 사용할 것이므로Authorization: Basic 헤더를 통한 인증 비활성화
    http.httpBasic(AbstractHttpConfigurer::disable);
    //스프링 시큐리티의 기본 로그인 방식으로, 우리가 만든 커스텀 로그인 로직을 사용할 것이므로 비홣성화
    http.formLogin(AbstractHttpConfigurer::disable);
    //이전에 만든 프론트 엔드와 연결하기 위해 CORS 설정 비활성화
    http.cors(AbstractHttpConfigurer::disable);
//    http.formLogin(formLogin -> formLogin
////                .loginProcessingUrl("/user/login")
//        .defaultSuccessUrl("/a/ex01"));

    http.authorizeHttpRequests(
        (auth) -> auth
            .requestMatchers("/login", "/user/signup").permitAll() // 아무 권한이 없어도 (로그인을 하지 않았더라도) /login 페이지와 /user/signup 페이지 접속 가능
            .requestMatchers("/a/ex01").hasAnyRole("USER", "ADMIN") // role이 USER와 ADMIN인 유저만 /a/ex01 페이지 접속 가능
            .requestMatchers("/b/ex01").hasRole("ADMIN") //role이 ADMIN인 유저만 /b/ex01 페이지 접속 가능
            .anyRequest().authenticated()
    );

    http.addFilterAt(new LoginFilter(configuration.getAuthenticationManager()), UsernamePasswordAuthenticationFilter.class);

    return http.build();
  }
}

SecurityFilterChain은 스프링 시큐리티의 핵심 구성 요소로, HTTP 요청이 들어올 때 시큐리티 필터들을 체인 형태로 연결하여 요청을 처리한다.
이 필터 체인은 인증(Authentication)인가(Authorization) 과정을 관리한다.

왜 SecurityFilterChain을 Bean으로 등록할까?
스프링은 DI(의존성 주입) 컨테이너를 통해 객체를 관리한다. 이때 스프링 부트는 자동 구성(Auto-Configuration) 기능을 통해 시큐리티 설정을 자동으로 적용하는데, @Bean으로 등록하면 스프링 시큐리티가 이를 감지하여 기본 시큐리티 설정 대신 개발자가 정의한 설정을 사용한다.
또한 SecurityFilterChain을 여러 개 등록하면 요청 경로별로 다른 보안 정책을 적용할 수 있으며, @Order를 사용하여 필터 체인의 적용 순서를 제어할 수 있다.
마지막으로 @Bean으로 등록하면 스프링의 IoC 컨테이너에서 관리되어, 다른 컴포넌트나 서비스에 쉽게 주입할 수 있다.

@Component vs @Bean

@Compoent@Bean
프로젝트가 실행될 때 컴포넌트 스캔을 통해서 객체를 생성해서 스프링 컨테이너에 Bean으로 등록컴포넌트 스캔 X, 개발자가 직접 객체를 생성해서 스프링 컨테이너에 Bean으로 등록
개발자가 직접 개발한 클래스의 객체를 등록할 때 사용라이브러리를 가져와서 라이브러리의 객체를 등록할 때 사용

http.* 역할 정리

설정기본 역할비활성화 이유
http.csrf().disable()CSRF 보호 (세션 기반)REST API에서는 불필요
http.httpBasic().disable()HTTP Basic 인증 (Authorization 헤더)커스텀 로그인 방식 사용 시 불필요
http.formLogin().disable()기본 로그인 폼 제공JSON 기반 로그인 API 사용 시 불필요
http.cors().disable()CORS 정책 관리개발자가 직접 CORS 설정 시 불필요

JwtUtil과 JwtFilter

JWT는 JSON Web Token의 약자로, 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰이다.
우리는 JSON 형태로 데이터를 주고 받을 것이므로 해당 데이터 폼을 처리하기 위한 클래스를 추가한다.

자세한 정보는 JWT란?을 참고

//build.gradle
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

buidl.gradle에 위의 라이브러리를 추가한다.

//JwtUtil.java
public class JwtUtil {
  private static final String SECRET = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789";
  private static final int EXP = 30 * 60 * 1000;


  public static Member getMember(String token) {
    try {
      Claims claims = Jwts.parserBuilder()
          .setSigningKey(SECRET)
          .build()
          .parseClaimsJws(token)
          .getBody();
      return Member.builder()
          .idx(claims.get("memberIdx", Long.class))
          .email(claims.get("memberEmail", String.class))
          .role(claims.get("memberRole", String.class))
          .nickName(claims.get("memberNickname", String.class))
          .build();
    } catch (ExpiredJwtException e) {
      System.out.println("토큰이 만료되었습니다!");
      return null;
    }
  }

  public static String generateToken(Long memberIdx, String memberEmail, String memberRole, String memberNickName) {
    Claims claims = Jwts.claims();
    claims.put("memberRole", memberRole);
    claims.put("memberEmail", memberEmail);
    claims.put("memberIdx", memberIdx);
    claims.put("memberNickname", memberNickName);
    String token = Jwts.builder()
        .setClaims(claims)
        .setIssuedAt(new Date(System.currentTimeMillis()))
        .setExpiration(new Date(System.currentTimeMillis() + EXP))
        .signWith(SignatureAlgorithm.HS256, SECRET)
        .compact();
    return token;
  }
}
//JwtFilter.java
public class JwtFilter extends OncePerRequestFilter {
  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    Cookie[] cookies = request.getCookies();
    String jwtToken = null;
    if(cookies != null) {
      for(Cookie cookie : cookies) {
        if(cookie.getName().equals("ATOKEN")) {
          jwtToken = cookie.getValue();
        }
      }
    }

    if(jwtToken != null) {
      Member member = JwtUtil.getMember(jwtToken);

      if(member != null) {
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
            new UsernamePasswordAuthenticationToken(member, null, member.getAuthorities());
        usernamePasswordAuthenticationToken.setDetails(member);

        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
      }
    }
    filterChain.doFilter(request, response);
  }
}

로그인

SecurityConfig

//SecurityConfig.java
http.addFilterAt(new LoginFilter(configuration.getAuthenticationManager()), UsernamePasswordAuthenticationFilter.class);
//기존에 있던 LoginFilter 아래에 JwtFilter를 수행하기 위한 선언을 추가해준다.
http.addFilterBefore(new JwtFilter(), UsernamePasswordAuthenticationFilter.class);

addFilterAt() : LoginFilter를 UsernamePasswordAuthenticationFilter와 같은 위치에 배치한다. 즉, UsernamePasswordAuthenticationFilter가 원래 있던 자리에 LoginFilter가 대체(override) 된다. 결과적으로 UsernamePasswordAuthenticationFilter는 더 이상 동작하지 않고, LoginFilter가 그 역할을 대신한다.

addFilterBefore() : JwtFilter를 UsernamePasswordAuthenticationFilter 바로 앞에 추가한다.

JwtFilter는 UsernamePasswordAuthenticationFilter 앞에 배치되지만, 이미 UsernamePasswordAuthenticationFilter는 LoginFilter로 대체되었기 때문에 JwtFilter는 LoginFilter 바로 앞에 위치하게 된다.

✅ 일반 요청 (GET /user/info)이든 로그인 요청 (POST /login)이든 JwtFilter는 http 요청이 들어오면 제일 앞에서 무조건 실행되지만, LoginFilter의 경우 로그인에 실패하면 실행되지 않는다.

💡

LoginFilter

//LoginFilter.java
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;

    // 원래는 form-data 형식으로 사용자 정보를 입력받지만 우리는 JSON 형태로 입력을 받기 위해서 재정의
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        System.out.println("LoginFilter 실행됐다.");
        UsernamePasswordAuthenticationToken authToken;
        // 그림에서 1번 로직
//        UserDto.SignupRequest userDto =
//                new UserDto.SignupRequest(request.getParameter("username"), request.getParameter("password"));
        try {
            // 그림에서 원래 1번이었던 로직을 JSON 형태의 데이터를 처리하도록 변경
            UserDto.SignupRequest userDto  = new ObjectMapper().readValue(request.getInputStream(), UserDto.SignupRequest.class);

            // 그림에서 2번 로직
            authToken =
                    new UsernamePasswordAuthenticationToken(userDto.getEmail(), userDto.getPassword(), null);

        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        // 그림에서 3번 로직
        return authenticationManager.authenticate(authToken);
    }

    
    // 그림에서 10번 로직
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        User user = (User) authResult.getPrincipal();
        String jwtToken = JwtUtil.generateToken(user.getIdx(), user.getEmail(), user.getRole());

        ResponseCookie cookie = ResponseCookie
                .from("ATOKEN", jwtToken)
                .path("/")
                .httpOnly(true)
                .secure(true)
                .maxAge(Duration.ofHours(1L))
                .build();
        response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());
    }
    
    //만약 SecurityConfig를 제대로 설정했음에도 403 에러가 뜨고 원인을 모르겠다면, 아래의 코드를 추가하면 자세한 정보를 볼 수 있다.
    @Override
  	protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed)
      throws IOException, ServletException {
    	response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);  // ✅ 401 Unauthorized 반환
    	response.getWriter().write("Authentication Failed: " + failed.getMessage());
  }
}

UserRepository

//UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String username);
}

사용자가 입력한 이메일(username)과 데이터베이스(DB) 테이블의 값이 일치하는지 조회(DB 쿼리)

UserService

// 5번 로직
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<User> result = userRepository.findByEmail(username);

        if (result.isPresent()) {
            // 7번 로직, 사용자 존재 시 User 객체 반환 (UserDetails를 구현한 클래스여야 함)
            User user = result.get();
            return user; // 여기서 User는 UserDetails 인터페이스를 구현한 클래스여야 함
        }
        return null;
    }

loadUserByUsername() 메서드는 스프링 시큐리티의 인증(Authentication) 과정에서 사용자 정보를 데이터베이스에서 조회하기 위한 핵심 메서드로, UserDetailsService 인터페이스에 정의되어 있으며 로그인시 스프링 시큐리티가 이 메서드를 자동으로 호출한다.

로그인 성공시 JwtUtil에서 설정한 토큰이 발행된 모습

0개의 댓글