BE/Spring Boot

[Spring Boot] Spring Security란? / 인증 수행 과정 / 인가 처리

셰욘 2025. 2. 10. 20:50
728x90

Spring Security란?

스프링 기반 애플리케이션의 인증(Authentication)인가(Authorization)를 담당하는 보안 프레임워크
로그인, 접근 제어, CSRF 방어, 세션 관리, OAuth2 지원 등의 다양한 보안 기능을 제공한다.

 

 

Gradle 추가

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
}

 

 

Spring Security 설정 변경 (SecurityConfig)

Spring Boot 3.x부터는 SecurityFilterChain을 사용하여 보안 설정을 변경해야 한다.

@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);
        // http 기본 인증(아이디/비밀번호 입력 창) 비활성화
        http.httpBasic(AbstractHttpConfigurer::disable);
        
        // 폼 로그인 비활성화 (기본적으로 제공되는 로그인 폼 사용 x)
        http.formLogin(AbstractHttpConfigurer::disable);
        
        // 접근 권한 설정 (인가 - Authorization)
        http.authorizeHttpRequests(
                (auth) -> auth
                        .requestMatchers("/login").permitAll()  // 누구나 접근 가능
                        .requestMatchers("/user/signup").permitAll()  // 누구나 접근 가능
                        .requestMatchers("/a/ex01").hasRole("USER")  // "ROLE_USER"인 사용자만 접근 가능
                        .anyRequest().authenticated()  // 그 외 요청은 로그인 필요
        );
        
        // 커스텀 로그인 필터 추가
        http.addFilterAt(new LoginFilter(configuration.getAuthenticationManager()), UsernamePasswordAuthenticationFilter.class);
        // JWT 필터 추가
        http.addFilterBefore(new JwtFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

 

 


인증(Authentication)

누구인지 확인하는 과정 (로그인)

  • 사용자의 아이디와 비밀번호를 확인하고, 시스템에 접근할 수 있도록 인증하는 과정

 

인증 수행 과정 (UsernamePasswordAuthenticationFilter)

 

 

1. 클라이언트 요청 -> AuthenticationFilter

  • 클라이언트가 HTTP 요청을 보내면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임되고, 그 중UsernamePasswordAuthenticationFilter(위 그림에서는AuthenticationFilter)에서 인증을 처리한다.
// MemberDto.SignupRequest MemberDto =
//     new MemberDto.SignupRequest(request.getParameter("Membername"), request.getParameter("password"));

// JSON 형태의 데이터를 처리하도록 변경
MemberDto.SignupRequest MemberDto  = new ObjectMapper().readValue(request.getInputStream(), MemberDto.SignupRequest.class);

 

 

2. AuthenticationFilter에서 토큰 생성

  • AuthenticationFilter는 요청 객체(HttpServletRequest)에서 username과 password를 추출해서 토큰을 생성한다.
authToken = new UsernamePasswordAuthenticationToken(MemberDto.getEmail(), MemberDto.getPassword(), null);

 

 

3. 토큰 생성 후 AutheicationManager에게 토큰 전달

  • AutheicationManager는 인터페이스다.
  • 일반적으로 사용되는 구현체는 ProviderManager
return authenticationManager.authenticate(authToken);

 

 

4. ProviderManager는 인증을 위해 AuthenticationProvider로 토큰을 전달한다.

 

 

5. AuthenticationProvider는 토큰의 정보를 UserDetailsService에 전달한다.

 

 

6. UserDetailsService는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아 UserDetails 객체를 생성한다.

@RequiredArgsConstructor
@Service
public class MemberService implements UserDetailsService {
	//...
}

// UserDetailsService 인터페이스
public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

 

 

7. 생성된 UserDetails 객체는 AuthenticationProvider로 전달되며, 해당 Provider에서 인증을 수행하고 성공하게 되면 ProviderManger로 권한을 담은 토큰을 전달한다.

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Optional<Member> result = memberRepository.findByEmail(username);

    if (result.isPresent()) {
        Member member = result.get();
        return member;
    }

    return null;
}

 

 

8. ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달한다.

 

 

9. AuthenticationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장한다.

 SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);

 

 

 


 

LoginFilter

Member 엔티티에서 이메일과 비밀번호를 가져와 토큰을 생성하고 인증을 처리해준다.

@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번 로직
//        MemberDto.SignupRequest MemberDto =
//                new MemberDto.SignupRequest(request.getParameter("Membername"), request.getParameter("password"));
        try {
            // 그림에서 원래 1번이었던 로직을 JSON 형태의 데이터를 처리하도록 변경
            MemberDto.SignupRequest MemberDto  = new ObjectMapper().readValue(request.getInputStream(), MemberDto.SignupRequest.class);

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

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

        return authenticationManager.authenticate(authToken);
    }
    
    // 로그인 성공했을 때 응답 처리
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        Member Member = (Member) authResult.getPrincipal();
        String jwtToken = JwtUtil.generateToken(Member.getIdx(), Member.getEmail(), Member.getNickName(), Member.getRole());

        // 빌더 패턴으로 객체를 생성
        // 쿠키 생성
        ResponseCookie cookie = ResponseCookie
                .from("ATOKEN", jwtToken)
                .path("/")
                .httpOnly(true)
                .secure(true)
                .maxAge(Duration.ofHours(1L))
                .build();
                
        // 생성한 쿠키를 헤더에 등록
        response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());

    }
}

 

 

필터들을 추가해준다.

 

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

Sprint Security 주요 모듈

 

Authentication

현재 접근하는 주체의 정보와 권한을 담는 인터페이스

더보기
public interface Authentication extends Principal, Serializable {
    // 현재 사용자의 권한 목록
    Collection<? extends GrantedAuthority> getAuthorities();

    Object getCredentials();

    Object getDetails();

    Object getPrincipal();

    // 인증 여부
    boolean isAuthenticated();

    // 인증 여부 설정
    void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

 

 

SecurityContext

Authentication을 보관하는 역할

SecurityContext를 통해 Authentication 객체를 꺼내올 수 있음

더보기
public interface SecurityContext extends Serializable {
    Authentication getAuthentication();

    void setAuthentication(Authentication authentication);
}

 

SecurityContextHolder

보안 주체의 세부 정보를 포함하여 응용 프로그램의 현재 SecurityContext에 대한 세부 정보가 저장된다.

 

  • 인증에 성공하면 principal과 credential 정보를 Authentication에 담는다.
  • 그리고 Spring Security에서 Authetication을 SecurityContext에 보관한다.
  • 보관한 SecurityContext를 SecurityContextHolder에 담아 보관한다.

 

 

UserDetails

스프링 시큐리티가 인증하는 데 사용하는 사용자 정보 클래스 

 

인증에 성공하여 생성된 UserDetails 객체는 토큰을 생성하기 위해 사용된다.

엔티티에 UserDetails를 implements하여 처리한다.

 

더보기
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity
public class Member implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long idx;
    private String email;
    private String password;
    private String nickName;
    private String role;
    private boolean enabled;
    @OneToMany(mappedBy = "member")
    private List<EmailVerify> emailVerifyList = new ArrayList<>();

    @OneToMany(mappedBy = "member")
    private List<Course> courseList = new ArrayList<>();


    public void verify() {
        this.enabled = true;
    }

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

        authorities.add(authority);
        return authorities;
    }


    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
}

 

 

UserDetailsService

스프링 시큐리티에서 사용자 정보를 조회하는 데 사용하는 클래스

 

UserDetails 객체를 반환하는 하나의 메서드만 가지고 있다. 

일반적으로 이것을 UserRepository 를 주입받아 DB와 연결하여 처리한다.

더보기
@RequiredArgsConstructor
@Service
public class MemberService implements UserDetailsService {
    private final MemberRepository memberRepository;

    public void signup(MemberDto.SignupRequest dto) {
        // 회원가입 로직
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> result = memberRepository.findByEmail(username);

        if (result.isPresent()) {
            Member member = result.get();
            return member;
        }

        return null;
    }
}

 

 

 

UsernamePasswordAuthenticationToken

Authentication을 implements한 AbstractAuthenticationToken의 하위 클래스로,

User의 ID가 Principal 역할을 하고 Password가 Credential의 역할을 한다. 
UsernamePasswordAuthenticationToken의 첫 번째 생성자는 인증 전의 객체를 생성하고, 두 번째는 인증이 완료된 객체를 생성한다.

더보기
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 620L;
    private final Object principal;
    private Object credentials;
    
    // 인증 완료 전 객체 생성
    public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
        super((Collection)null);
        this.principal = principal;
        this.credentials = credentials;
        this.setAuthenticated(false);
    }

    // 인증 완료 후 객체 생성
    public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true);
    }
}

 

 

AuthenticationManager

인증에 대한 부분은 AuthenticationManager를 통해서 처리하게 되는데,

실질적으로는 AuthenticationManager에 등록된 AuthenticationProvider에 의해 처리된다.
 
인증에 성공하면 생성자를 이용해 객체를 생성하여 SecurityContext에 저장한다

더보기
@FunctionalInterface
public interface AuthenticationManager {
    Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

 

AuthenticationProvider

 

AuthenticationProvider에서는 실제 인증에 대한 부분을 처리하는데,

인증 전의 Authentication 객체를 받아서 인증이 완료된 객체를 반환하는 역할을 한다.

 

 

 


스프링 시큐리티 로그인

  LoginFilter UserDetails UserDetailsService
기존 세션 정보 저장 username, password 메모리에서 사용자 조회
변경 토큰 정보 응답 enabled, nickname 등 추가 DB에서 사용자 조회

 

 


인가 (Authorization)

누구인지 확인 후 권한을 부여해주는 것 (권한 부여)

 

ROLE

role로 사용자를 구분해서 특정 role로 접근이 가능한지 확인한다.

 

ex) USER role을 가진 사용자만 /a/ex01에 접근 가능

.requestMatchers("/a/ex01").hasRole("USER")

 

 

 

Enabled

엔티티에 enabled 속성을 추가해서 enabled를 통해 권한을 확인한다.

public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long idx;
    private String email;
    private String password;
    private String role;
    private boolean enabled;
    
    @Override
    public boolean isEnabled() {
        return enabled;
    }
    
    // 다른 메소드 생략   
}

 

 

enabled가 false면 403 에러가 뜬다.

 

 

로그인 요청을 보냈을 때 enabled = 0이면 403 에러가 뜬다.

 

 

 

enabled가 true면 200 ok 응답이 온다.

 

 

로그인 요청을 보냈을 때 enabled = 1이면 200 OK로 응답이 잘 온다.

 

 

 


전체 구현 코드

📌 JwtUtil

토큰 생성

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.util.Date;

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("userIdx", Long.class))
                    .email(claims.get("userEmail", String.class))
                    .nickName(claims.get("userEmail", String.class))
                    .role(claims.get("userRole", String.class))
                    .build();

        } catch (ExpiredJwtException e) {
            System.out.println("토큰이 만료되었습니다!");
            return null;
        }
    }

    public static String generateToken(Long userIdx, String userEmail, String userNickName, String userRole) {
        Claims claims = Jwts.claims();
        claims.put("userRole", userRole);
        claims.put("userEmail", userEmail);
        claims.put("userNickName", userNickName);
        claims.put("userIdx", userIdx);
        String token = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + EXP))
                .signWith(SignatureAlgorithm.HS256, SECRET)
                .compact();
        return token;
    }

    public static boolean validate(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(SECRET)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (ExpiredJwtException e) {
            System.out.println("토큰이 만료되었습니다!");
            return false;
        }
        return true;
    }
}

 

 

 


📌 LoginFilter

회원 정보 확인 후 로그인 처리

ATOKEN에 토큰 쿠키 설정

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.time.Duration;

@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;

        try {
            MemberDto.SignupRequest MemberDto  = new ObjectMapper().readValue(request.getInputStream(), MemberDto.SignupRequest.class);

            authToken =
                    new UsernamePasswordAuthenticationToken(MemberDto.getEmail(), MemberDto.getPassword(), null);

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

        return authenticationManager.authenticate(authToken);
    }

    
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        Member Member = (Member) authResult.getPrincipal();
        String jwtToken = JwtUtil.generateToken(Member.getIdx(), Member.getEmail(), Member.getNickName(), Member.getRole());

        
//        일반적인 객체 생성 및 객체의 변수에 값을 설정하는 방법
//        ResponseCookie cookie = new ResponseCookie();
//        cookie.setPath("/");
//        cookie.setHttpOnly(true);

//        빌더 패턴으로 객체를 생성하면서 값을 설정하는 방법
        ResponseCookie cookie = ResponseCookie
                .from("ATOKEN", jwtToken)
                .path("/")
                .httpOnly(true)
                .secure(true)
                .maxAge(Duration.ofHours(1L))
                .build();

        response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());
    }
}

 

 


📌 JwtFilter

쿠키에 설정된 토큰 확인 후 인가 처리

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;

public class JwtFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        System.out.println("JwtFilter 실행됐다.");
        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는 위에 있음!

728x90