반응형

Spring Security는 Spring 기반 애플리케이션의 보안을 제공하는 강력하고, 유연한 프레임워크입니다.

특징

 

인증(Authentication)과 인가(Authorization) 기능 제공

다양한 보안 공격 방어 (CSRF, XSS 등)

확장 가능하고 커스터마이징이 용이

 

 

프로젝트 라이브러리

build.gradle

    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'

spring security 뿐 아니라 API 에서 jwt 토큰을 인증 및 인가 하는 기능을 사용하려면 jsonwebtoken 라이브러리도 추가하여야 한다. 

 

인증 개념

(Authentication)

인증은 사용자의 신원을 확인하는 과정입니다.

 

인가 개념

(Authorization)

인가는 사용자가 특정 리소스에 접근할 수 있는 권한이 있는지 확인하는 과정입니다.

 

JwtRequestFilter

@Component
public class JwtRequestFilter extends OncePerRequestFilter {

    private UserDetailsService userDetailsService;
    private JwtUtil jwtUtil;
    @Autowired
    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Autowired
    public void setJwtUtil(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        final String authorizationHeader = request.getHeader("Authorization");

        String username = null;
        String jwt = null;

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            username = jwtUtil.extractUsername(jwt);
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            if (jwtUtil.validateToken(jwt, userDetails)) {

                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }
        chain.doFilter(request, response);
    }
}

 

JwtUtil

@Component
public class JwtUtil {

//    private String SECRET_KEY = Base64.getEncoder().encodeToString("secret".getBytes());

    private final SecretKey SECRET_KEY = Keys.secretKeyFor(SignatureAlgorithm.HS256);


    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(SECRET_KEY)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    private Boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    public String generateToken(UserDetails userDetails) {
        return createToken(userDetails.getUsername());
    }

    private String createToken(String subject) {
        return Jwts.builder()
                .setSubject(subject)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10)) // 10시간 유효
                .signWith(SECRET_KEY)
                .compact();
    }


    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }
}

 

SecurityConfig

@Configuration
public class SecurityConfig {

    private final JwtRequestFilter jwtRequestFilter;

    //@Lazy 이렇게 하면 JwtRequestFilter 빈의 초기화를 지연시켜 순환 참조 문제를 피할 수 있습니다.
    @Autowired
    public SecurityConfig(@Lazy JwtRequestFilter jwtRequestFilter) {
        this.jwtRequestFilter = jwtRequestFilter;
    }



    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
//        http
//                .authorizeHttpRequests(authorizeRequests ->
//                        authorizeRequests
//                                .requestMatchers("/admin/**").hasRole("ADMIN")
//                                .requestMatchers("/user/**").hasRole("USER")
//                                .anyRequest().authenticated()
//                )
//          •  authorizeHttpRequests 메서드를 사용하여 HTTP 요청에 대한 인가(Authorization) 규칙을 설정합니다.
//          •  requestMatchers("/admin/**").hasRole("ADMIN"): URL 패턴이 /admin/**인 요청은 ADMIN 역할을 가진 사용자만 접근할 수 있도록 설정합니다.
//          •   requestMatchers("/user/**").hasRole("USER"): URL 패턴이 /user/**인 요청은 USER 역할을 가진 사용자만 접근할 수 있도록 설정합니다.
//          •   anyRequest().authenticated(): 나머지 모든 요청은 인증된 사용자만 접근할 수 있도록 설정합니다.

//        .formLogin(formLogin ->
//                formLogin
//                        .loginPage("/login")
//                        .permitAll()
//        )
//          •  formLogin 메서드를 사용하여 폼 기반 로그인을 설정합니다.
//          •   loginPage("/login"): 사용자 정의 로그인 페이지를 설정합니다. 사용자가 /login URL로 접근하면 로그인 페이지가 표시됩니다.
//          •   permitAll(): 로그인 페이지는 인증되지 않은 사용자도 접근할 수 있도록 허용합니다.
//
//                .csrf(csrf -> csrf.disable()); // CSRF 보호 비활성화. 필요한 경우 활성화 가능
//          •  csrf 메서드를 사용하여 CSRF(Cross-Site Request Forgery) 보호 설정을 합니다.
//          •  csrf.disable(): CSRF 보호를 비활성화합니다. CSRF 보호가 필요할 경우 이 부분을 제거하거나 다른 방법으로 설정할 수 있습니다.
        http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(authorizeRequests ->
                        authorizeRequests
                                .requestMatchers("/api/auth/**").permitAll()
                                .anyRequest().authenticated()
                )
                .sessionManagement(sessionManagement ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        var userDetailsManager = new InMemoryUserDetailsManager();

        var user = User.withUsername("user")
                .password(passwordEncoder().encode("password"))
                .roles("USER")
                .build();

        var admin = User.withUsername("admin")
                .password(passwordEncoder().encode("admin"))
                .roles("ADMIN")
                .build();

        userDetailsManager.createUser(user);
        userDetailsManager.createUser(admin);

        return userDetailsManager;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }


    /**
     * Spring Security 설정 파일에서 AuthenticationManager를 빈으로 정의해야 합니다.
     * Spring Boot 2.x에서는 AuthenticationManager를 직접 정의하는 것이 필요했지만,
     * Spring Boot 3.x에서는 이를 자동으로 구성하지 않으므로 명시적으로 설정해야 합니다.
     * @param authenticationConfiguration
     * @return
     * @throws Exception
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
}

 

user 와 password 라는 아이디 및 비밀번호를 이용하면 로그인 가능하다.

 

AuthController

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtUtil jwtUtil;

    @PostMapping("/login")
    public String createAuthenticationToken(@RequestBody AuthRequest authRequest) throws Exception {
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(authRequest.getUsername(), authRequest.getPassword())
        );

        final UserDetails userDetails = userDetailsService.loadUserByUsername(authRequest.getUsername());
        final String jwt = jwtUtil.generateToken(userDetails);

        return jwt;
    }
}

 

AuthRequest

@Getter
@Setter
public class AuthRequest {
    private String username;
    private String password;
}

 

 

로그인시 

curl --location 'http://localhost:8080/api/auth/login' \
--header 'Content-Type: application/json' \
--data '{
"username": "user",
"password": "password"
}'

이렇게 요청할 경우 JWT 토큰을 응답 받고 응답받은 토큰을 API Request 헤더에 
Authtication : Bearar {토큰} 
으로 넣어주면 인가를 해주면 됩니다. 

인가를 원하지 않으면 SecurityConfig 클래스의 securityFilterChain 에 permit 하는 경로를 추가해 주면 됩니다.

 

Spring Security 5.4 이후부터는 WebSecurityConfigurerAdapter 대신 SecurityFilterChain을 사용하여 보안 구성을 합니다.

 

 

Spring Security에서 인증 및 인가 구현 시 사용되는 주요 개념들

 

1. Filter 체인

설명: Spring Security는 다양한 필터들의 체인으로 구성됩니다. 각 필터는 Request를 가로챈 후 일련의 절차를 처리합니다.

주요 필터: UsernamePasswordAuthenticationFilter는 사용자가 제출한 인증 정보를 처리합니다.

2. UsernamePasswordAuthenticationToken 생성

설명: UsernamePasswordAuthenticationFilter는 UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에게 전달합니다. 이 토큰에는 사용자가 제출한 인증 정보가 포함되어 있습니다.

3. AuthenticationManager

설명: AuthenticationManager는 실제로 인증을 수행하는데, 여러 AuthenticationProvider들을 이용합니다.

4. AuthenticationProvider

설명: 각각의 Provider들은 특정 유형의 인증을 처리합니다. 예시로 DaoAuthenticationProvider는 사용자 정보를 데이터베이스에서 가져와 인증을 수행합니다.

5. PasswordEncoder

설명: 인증과 인가에서 사용될 패스워드의 인코딩 방식을 지정합니다.

6. UserDetailsService

설명: AuthenticationProvider는 UserDetailsService를 사용하여 사용자 정보를 가져옵니다. UserDetailsService는 사용자의 아이디를 받아 loadUserByUsername을 호출하여 해당 사용자의 UserDetails를 반환합니다.

7. UserDetails

설명: UserDetails에는 사용자의 아이디, 비밀번호, 권한 등이 포함되어 있습니다.

8. Authentication 객체 생성

설명: 인증이 성공하면, AuthenticationProvider는 Authentication 객체를 생성하여 AuthenticationManager에게 반환합니다. 이 Authentication 객체에는 사용자의 세부 정보와 권한이 포함되어 있습니다.

9. SecurityContextHolder

설명: 현재 실행 중인 스레드에 대한 SecurityContext를 제공합니다.

10. SecurityContext

설명: 현재 사용자의 Authentication이 저장되어 있습니다. 애플리케이션은 SecurityContextHolder를 통해 현재 사용자의 권한을 확인하고, 인가 결정을 합니다.

 

인증 및 인가 설정에 대한 주요 사항

 

1. CSRF 보호 비활성화

설명: CSRF 토큰을 사용하지 않으므로 확인하지 않도록 설정합니다.

2. CORS 설정 적용

설명: 다른 도메인의 웹 페이지에서 리소스에 접근할 수 있도록 허용합니다.

3. 폼 로그인과 HTTP 기본 인증 비활성화

설명: Spring 웹 페이지에서 제공되는 로그인 폼을 통해 사용자를 인증하는 메커니즘과 HTTP 기본 인증을 비활성화합니다.

4. JwtAuthFilter 추가

설명: UsernamePasswordAuthenticationFilter 앞에 JwtAuthFilter를 추가하여, JWT 필터를 거치도록 설정합니다. JwtAuthFilter를 통해서 Authentication을 획득하였다면 인증된 자원에 접근할 수 있습니다.

5. 권한에 대한 규칙 작성

설명: anyRequest().permitAll() 설정은 기본적으로 모두 허용해 주지만, EnableGlobalMethodSecurity를 설정하는 이유는 Annotation으로 접근 제한을 설정하기 위함입니다.

추가 설명: 만약 Annotation을 통해 접근 제한을 하지 않을 것이라면, anyRequest() 부분에서 접근 승인할 엔드포인트들을 작성해야 합니다.

6. 인증과 인가 실패 시 Exception Handler 추가

설명: Security 단계에서 권한 관련 401이나 403 에러 등을 처리해 줄 핸들러를 함께 등록해줍니다.

authenticationEntryPoint: 인증되지 않은 사용자에 대해 처리하는 핸들러 정의

accessDeniedHandler: 인증되었지만, 특정 리소스에 대한 권한이 없는 경우(인가) 처리하는 핸들러 정의

 

요약

CSRF 보호 비활성화: CSRF 토큰 사용하지 않음.

CORS 설정: 다른 도메인의 웹 페이지에서 리소스 접근 허용.

폼 로그인과 HTTP 기본 인증 비활성화: Spring 웹 페이지의 로그인 폼과 HTTP 기본 인증 비활성화.

JwtAuthFilter 추가: UsernamePasswordAuthenticationFilter 앞에 JWT 필터 추가.

권한 규칙 작성: anyRequest().permitAll() 설정과 EnableGlobalMethodSecurity 설정.

Exception Handler 추가: 인증과 인가 실패 시 처리할 핸들러 정의.

반응형

+ Recent posts