개요

이번에는 OAuth2 Resource Server 를 이용해 JWT를 손쉽게 적용해보도록 하겠습니다.

 

 

JWT란?

들어가기에 앞서 JWT에 대해서 간단히 설명하자면 JWT(JSON Web Token)는 자원을 사용하기 전에 사용해도 되는 유저인지 판단할 수 있게 해주는 티켓이라고 생각하면됩니다. 그러니까 놀이공원에 들어갈 수 있게 해주는 입장권인겁니다. 놀이기구를 타기위해서는 놀이공원에 돈을 내야합니다. 그걸 증명하는게 입장권이죠. 놀이기구를 타기 전에 입장권을 검사함으로써 놀이기구(자원)을 사용하도록 직원이 허가 해줍니다. 여기서 중요시 봐야할 점은 표를 검사하는 직원은 어떤 경로를 통해 입장권을 구매했는지, 어떻게 입장권을 구했는지 알지도 못할 뿐더러 궁금해 하지도 않습니다. 그저 입장권이 있는지, 없는지만 판단하게되죠. JWT는 그런 역할을 하는 겁니다.

Web 환경에서는 Session과 Cookie를 활용해 입장권을 저장해놓을 수 있지만 REST API 로 확장하게되면 Session 과 같은 저장공간을 사용하지 못합니다. 따라서 HTTP Request Header부분에 유저의 정보를 보관해서 요청을 보내서 권한을 사용할 수 있는지 없는지 판단하게됩니다. 

 

JWT에 대한 상세한 글은 따로 작성하도록 하겠습니다.

 

 

 

OAuth2 Resource Server

먼저 OAuth2 Client와 OAuth2 Resource Server 의 차이점을 짚고 넘어가겠습니다.

oauth2-client는 보호된 자원을 사용하려고 접근 요청을 하는 애플리케이션입니다.

oauth2-resource-server 는 보호된 자원을 호스팅하는 역할을 합니다.

 

대략적인 순서는 다음과 같습니다.

 

  • Client가 Resource Server에 접근하기 위해 AccessToken 요청
  • 인증 후 AccessToken 발급받음
  • Client는 이 AccessToken을 사용해 Resource Server의 API 호출
  • Resource Server는 토큰을 검증하고 요청된 리소스 제공

그러니까 oauth2-resource-server에서 토큰을 검증하는 부분을 이번에 구현하는 것입니다.

 

HttpSecurity oauth2ResourceServer 메소드를 살펴보면

 

메소드 설명
bearerTokenResolver Bearer 토큰을 추출하는 방법을 커스텀할 수 있는 기능
accessDeniedHandler 권한이 없을 때 예외 핸들링
authenticationEntryPoint 잘못된 토큰일 때 예외 핸들링
jwt jwt 관련 설정
opaqueToken opaqueToken 관련 설정

 

 

이렇게 되어있습니다. 우리는 jwt를 사용할 것이기 때문에 opaqueToken 설정을 제외하고 나머지를 구현해주면됩니다.

 

 

 

 

JWT 인증 흐름

공식문서 https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html

 

  1. BearerTokenAuthenticationFilter 에서 추출된 토큰을 BearerTokenAuthenticationToken으로 만들어 AuthenticationManager로 전달합니다.
  2. AuthenticationProvider 의 구현체인 JwtAuthenticationProvider 에서 실제 인증절차를 거칩니다.
  3. 그 인증절차에는 비밀키를 이용해 JWT 토큰의 무결성(위변조 여부) 검사를 하는 JwtDecoder가 있고,
  4. JWT 토큰을 Authentication 객체로 변환해주는 JwtAuthenticationConverter가 있습니다.
  5. 인증이 성공하면 JwtAuthenticationToken을 반환하게되면서 인증과정이 종료됩니다.

이 과정에서 우리가 구현해야할 부분은

  • 비밀키와 함께 무결성검사를 하기위한 JwtDecoder
  • JWT 토큰의 payload(사용자정보)를 커스텀해 변경된 데이터를 저장하는 AuthenticationToken이 필요합니다. 따라서 JwtAuthenticationToken를 상속한 커스텀된 AuthenticationToken
  • 커스텀된 AuthenticationToken 을 convert 해줄 JwtAuthenticationConverter를 상속한 커스텀 converter

이렇게 3개가 필요합니다. 

 

 

구현하기 전에

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    private final SecretKey secretKey;
    public final int JWT_ACCESS_TIME = 60 * 60;
    public final int JWT_REFRESH_TIME = 60 * 60 * 24;

    public String generateAccessToken(TokenId tokenId, long userId,
                                        Collection<? extends GrantedAuthority> authorities) {
        return createToken(tokenId, userId, authorities, JWT_ACCESS_TIME);
    }
    public String generateRefreshToken(TokenId tokenId, long userId,
                                      Collection<? extends GrantedAuthority> authorities) {
        return createToken(tokenId, userId, authorities, JWT_REFRESH_TIME);
    }

    // 토큰 생성
    private String createToken(TokenId tokenId,
                              long userId,
                              Collection<? extends GrantedAuthority> authorities,
                              int expirationPeriod) {
        Date now = new Date(System.currentTimeMillis());

        return Jwts.builder()
            .header()
            .keyId(UUID.randomUUID().toString())
            .add("typ", "JWT")
            .and()
            .issuer("TEST_JWT")
            .claim("iss", tokenId.getProvider())
            .claim("sub", tokenId.getProviderId())
            .claim("userId", userId)
            .claim("scope", authorities.stream()
                .map(GrantedAuthority::getAuthority)
                .toList())
            .issuedAt(now)
            .expiration(new Date(now.getTime() + expirationPeriod * 1000L))
            .signWith(secretKey)
            .compact();
    }

}

커스텀된 JWT 양식입니다. 참고로만 사용해주세요.

권한정보를 넣을것이기 때문에 scope에 넣어주었습니다.

 

구현

 

application.yaml

jwt:
  secret: "{secret-key}"

 

 

JwtDecoder

@Configuration
@RequiredArgsConstructor
public class JwtConfig {

    private final SecretKey secretKey;

    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withSecretKey(secretKey)
            .macAlgorithm(MacAlgorithm.HS512)
            .build();
    }

}

JwtDecoder 에 비밀키와 알고리즘 HS512를 설정해주었습니다.

중요한 점은 SecretKey의 알고리즘과 JwtDecoder의 알고리즘이 서로 일치해야합니다.

 

HMAC-SHA 키를 생성할 때, 키의 길이에 따라 알고리즘이 자동 선택됩니다:

  • 256비트(32바이트) → HS256
  • 384비트(48바이트) → HS384
  • 512비트(64바이트) → HS512

 

 

CustomJwtToken

@Getter
public class CustomJwtToken extends JwtAuthenticationToken {

    private final Long userId;

    public CustomJwtToken(Jwt jwt, Collection<? extends GrantedAuthority> authorities) {
        super(jwt, authorities);
        this.userId = Long.valueOf(jwt.getClaimAsString("userId"));
    }

}

JwtAuthenticationToken을 상속에 만든 CustomJwtToken 입니다.

 

 

 

CustomJwtAuthentication

public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    private final JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();

    @Override
    public final AbstractAuthenticationToken convert(Jwt jwt) { // 3
        AbstractAuthenticationToken token = jwtAuthenticationConverter.convert(jwt); // 4
        Collection<GrantedAuthority> authorities = token.getAuthorities();
        return new CustomJwtToken(jwt, authorities);
    }

}

 

JwtAuthenticationConverter의 convert 메소드는 final 이라 Override할 수 없어서 JwtAuthenticationConverter를 필드에서 사용하였습니다.

 

 

JwtAuthenticationConverter 를 조금더 커스텀 하고싶다면?

public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    private final JwtAuthenticationConverter jwtAuthenticationConverter;

    public CustomJwtAuthenticationConverter() {
        this.jwtAuthenticationConverter = new JwtAuthenticationConverter();

        var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        // 권한정보가 들어가있는 claim key가 다른경우
        grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
        // ROLE_ 접두사가 필요한 경우
        grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");

        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
    }
    @Override
    public final AbstractAuthenticationToken convert(Jwt jwt) {
        AbstractAuthenticationToken token = jwtAuthenticationConverter.convert(jwt);
        Collection<GrantedAuthority> authorities = token.getAuthorities();
        return new CustomJwtToken(jwt, authorities);
    }

}

JwtAuthenticationConverter는 Authorities "scope", 접두사 "SCOPE_" 이 기본값입니다. 이를 수정하고싶은경우

위와 같이 설정하면됩니다. 저 같은 경우 JWT Token을 생성할때 GrantedAutority에서 authority를 꺼냈기 때문에 접두사가 붙은 상태로 생성되었습니다. 따라서 

 

public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    private final JwtAuthenticationConverter jwtAuthenticationConverter;

    public CustomJwtAuthenticationConverter() {
        this.jwtAuthenticationConverter = new JwtAuthenticationConverter();

        var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthorityPrefix("");

        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
    }
    @Override
    public final AbstractAuthenticationToken convert(Jwt jwt) {
        AbstractAuthenticationToken token = jwtAuthenticationConverter.convert(jwt);
        Collection<GrantedAuthority> authorities = token.getAuthorities();
        return new CustomJwtToken(jwt, authorities);
    }

}

접두사를 제거해서 사용하였습니다.

 

 

권한정보를 담지않는다면 위 과정은 생략해도 됩니다.

 

 

SecurityConfig

이전에 개발해놓았던 서버는 Web 기반이었습니다. 따라서 Security 설정을 API와 WEB을 구분해서 만들겠습니다.

 

@Bean
@Order(1)
SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ObjectMapper objectMapper, JwtTokenProvider jwtTokenProvider) throws Exception {

    http.securityMatcher("/api/**")

        .csrf(AbstractHttpConfigurer::disable)
        .cors(cors -> cors
            .configurationSource(apiConfigurationSource())
        )
        .authorizeHttpRequests(request -> request
            .requestMatchers("/api/user/**").hasAnyRole(Role.USER.name(), Role.ADMIN.name())
            .requestMatchers("/api/admin/**").hasRole(Role.ADMIN.name())
            .requestMatchers(HttpMethod.POST, "/api/login").anonymous()
            .anyRequest().permitAll()
        )
        .sessionManagement(session -> session
           .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )
        // oauth2-resource-server 설정 부분
        .oauth2ResourceServer(server -> server
            .jwt(jwt -> jwt
                .jwtAuthenticationConverter(new CustomJwtAuthenticationConverter())
            )
        );
    return http.build();
}

@Bean
@Order(2)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http, RememberMeServices rememberMeServices) throws Exception {

    http.securityMatcher("/**")

        .authorizeHttpRequests(request -> request
            .requestMatchers("/user/**").hasAnyRole(Role.USER.name(), Role.ADMIN.name())
            .requestMatchers("/admin/**").hasRole(Role.ADMIN.name())
            .requestMatchers("/login", "/signup").anonymous()
            .anyRequest().permitAll()
        )

        /// ... 이전 설정과 동일

    return http.build();
}

// SecretKey Bean 등록
@Bean
SecretKey secretKey(@Value("${jwt.secret}") String secretKey) {
    return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}

 

 

 

결과

@RestController
@RequiredArgsConstructor
public class RestMainController {


    @GetMapping("/api/user/profile")
    public ResponseEntity<Long> userProfile() {
        CustomJwtToken customJwtToken = (CustomJwtToken) SecurityContextHolder.getContext().getAuthentication();
        return ResponseEntity.ok(customJwtToken.getUserId());
    }

}

 

이렇게 테스트할 Controller를 만들고, 만들어진 JWT로 시도해봅시다.

 

정상적인 토큰인 경우

 

만료된 토큰인 경우

 

 

조금 더 간단하게 써보자

CustomJwtToken customJwtToken = (CustomJwtToken) SecurityContextHolder.getContext().getAuthentication();

일단 이렇게 쓰는게 너무 마음에 안듭니다.

 

public class JwtUserContextHolder {

    public static CustomJwtToken getJwtToken() {
        return (CustomJwtToken) SecurityContextHolder.getContext().getAuthentication();
    }
}
    @GetMapping("/user/profile")
    public ResponseEntity<Long> userProfile() {
        CustomJwtToken customJwtToken = JwtUserContextHolder.getJwtToken();
        return ResponseEntity.ok(customJwtToken.getUserId());
    }

 

이렇게 바꿨습니다. 이것도 조금 마음에 안듭니다. 어노테이션을 활용해서 변수로 바로 가져오게 하고싶습니다.

 

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface JwtToken {
}

@Component
public class JwtTokenArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(JwtToken.class)
            && parameter.getParameterType().equals(CustomJwtToken.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return JwtUserContextHolder.getJwtToken();
    }
}
@GetMapping("/user/profile")
public ResponseEntity<Long> userProfile(@JwtToken CustomJwtToken customJwtToken) {
    return ResponseEntity.ok(customJwtToken.getUserId());
}

 

엄청 깔끔해 졌네요.

 

 

 

 

결론

사실 이번 글에서 JWT에 관한걸 모두 다루려고 했습니다. 그런데 JWT 발급부분 (oauth2-client)과 JWT 인증부분(oauth2-resource-server) 의 역할이 구분되어있는데 하나의 글로 작성하면 혼란이 생길것같아서 어쩔 수 없이 나눠서 글을 작성했습니다.

위의 코드들은 이미 한 번 모두 구현하고 나서 파트별로 지우고 추가하면서 약간의 컴파일 오류가 생길 수는 있습니다. 그러니 따라치지 마시고 흐름만 이해하고 넘어가주시기 바랍니다. 공부하려고 글을 작성하는 거지 저도 배우는 입장이라 틀릴 수 있다는 부분 알아주셨으면 좋겠습니다.

HttpSecurity

어질어질하다

 

Spring Security는 설정이 대부분일 정도로 Security 설정하는 부분이 정말 많습니다. 또, 6.x.x 버전까지 오면서 Deprecated된 메소드들도 정말 많은데요 중간점검할겸 메소드들을 좀 정리해봤습니다.

1. 보안 설정

1-1 .cors()

  • CORS 설정
  • 크로스 도메인 요청 처리
http.cors(cors -> cors
    .configurationSource(corsConfigurationSource));

 

 

[CS][Spring Security] CORS에 대해서 알아보자

1. CORS란 무엇인가?CORS(Cross-Origin Resource Sharing)는 웹 브라우저에서 외부 도메인 리소스를 안전하게 요청할 수 있도록 하는 표준 규약입니다. 프론트엔드와 백엔드가 분리하는데 있어 CORS에 대해서

tmd8633.tistory.com

 

 

1-2. csrf()

  • CSRF 보호 설정
  • CSRF 토큰 처리
http.csrf(csrf -> csrf
    .ignoringAntMatchers("/api/**")
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));

 

 

[CS][Spring Security] CSRF란?

CSRF란?CSRF Cross-Site Request Forgery의 약자로 인증된 사용자의 권한을 악용하여 해당 사용자가 의도하지 않은 요청을 웹사이트에 전송하는 공격 기법입니다. 공격자는 사용자가 이미 인증된 상태를

tmd8633.tistory.com

 

 

1-3. headers()

  • 보안 헤더 설정
  • XSS 방지, 클릭재킹 방지 등
http.headers(headers -> headers
    .defaultsDisabled()  // 기본 설정을 비활성화하고 커스텀 설정 시작
    .frameOptions(frame -> frame.deny())  // X-Frame-Options
    .xssProtection(xss -> xss.enable())   // X-XSS-Protection
    .contentTypeOptions(content -> content.disable())  // X-Content-Type-Options
    .cacheControl(cache -> cache.disable())  // Cache-Control
);

이 부분에 대해서는 아직 공부하지 못했습니다. 공부하고 정리해서 글 쓴다음에 이부분 업데이트 하겠습니다.

 

 

1-4. x509()

  • X.509 인증서 기반 인증
  • 클라이언트 인증서 인증
http.x509(x509 -> x509
    .subjectPrincipalRegex("CN=(.*?)(?:,|$)")     // CN(Common Name)을 추출하는 정규식
    .userDetailsService(userDetailsService))       // 인증서의 사용자 정보와 매핑

주로, SSL/TLS 클라이언트 인증에 사용되며 높은 수준의 보안이 필요한 B2B 환경이나 금융시스템에서 사용된다고 합니다.

 

서버간 통신(Server-to-Server)

  • 마이크로서비스 간 통신
  • API 게이트웨이 인증

IoT 디바이스 인증

  • 각 디바이스에 고유 인증서 발급
  • 디바이스 인증 관리

금융권 시스템

  • 공인인증서 기반 인증
  • 보안이 중요한 기업 시스템

 

이 부분도 자세히 몰라서 공부하고 업데이트 하겠습니다.

 

 

1-5. jee()

  • Java EE 보안 설정
  • 컨테이너 기반 인증
http.jee(jee -> jee
    .mappableRoles("USER", "ADMIN"));

 

Java EE (Enterprise Edition) 기반의 보안 설정을 구성하는 데 사용됩니다 하지만 현재는 잘 사용하지 않고 JWT, OAuth2 같은 인증 방식을 사용합니다. 그러니까 예전 어플리케이션에서 사용된다고만 알고있으면 됩니다.

 

 

1-6. requiresChannel()

  • 채널 보안 요구사항
http.requiresChannel(channel -> channel
        .requestMatchers("/secure/**").requiresSecure()     // HTTPS 필수
        .requestMatchers("/public/**").requiresInsecure()   // HTTP 허용
        .anyRequest().requiresSecure()                      // 나머지는 모두 HTTPS
    );

HTTP, HTTPS 허용에 대한 설정입니다. 사실 프로덕션 환경에서는 모든 요청에 HTTPS를 사용하는 것이 권장되기때문에 이런 설정을 보통 하지 않고 일괄적으로 HTTPS 설정을 하곤합니다.

 

 

http.requiresChannel(channel -> channel
        .anyRequest().requiresSecure() // 모든 요청에 HTTPS 필수
    );

 

 

2. 기본 인증 설정

2-1. httpBasic()

  • HTTP Basic 인증 활성화
  • 간단한 사용자명/비밀번호 기반으로 인증
http.httpBasic(basic -> basic
    .realmName("My App")
    .authenticationEntryPoint(customEntryPoint));

username:password 형식으로 Base64로 인코딩되기 때문에 보안에 취약할 수 있습니다.

http.formLogin() 과 같이 설정을 할 경우 formLogin 이 우선적용됩니다.

REST API에서 커스텀해서 사용할 수는 있으나 그냥 JWT나 쓰는게 더 안전합니다.

 

 

2-2. anonymous()

  • 익명 사용자 접근에 대한 설정
http.anonymous(anonymous -> anonymous
    .principal("guest")
    .authorities("ROLE_GUEST"));

아래 글에서 한번 다룬적이 있습니다.

 

 

[Spring Security] 스프링 시큐리티 Anonymous과 ExceptionHandling (4)

Anonymous 인증이란?Spring Security의 Anonymous 인증은 인증되지 않은 사용자(로그인하지 않은 사용자)를 처리하는 메커니즘입니다. 인증되지 않은 요청에 대해 AnonymousAuthenticationToken을 생성하여 보안

tmd8633.tistory.com

 

 

 

3. 폼 로그인

3-1. formLogin()

  • Form 기반 로그인 설정
  • 로그인 성공/실패 Handling
http.formLogin(form -> form
    .loginPage("/login")
    .defaultSuccessUrl("/home")
    .failureUrl("/login?error=true"));

 

 

3-2. logout()

  • 로그아웃 처리 설정
  • 로그아웃 후 Handling
http.logout(logout -> logout
    .logoutUrl("/logout")
    .logoutSuccessUrl("/login")
    .deleteCookies("JSESSIONID"));

 

formLogin, logout 에 대해서도 작성한 글이 있습니다.

 

[Spring Security] 스프링 시큐리티 시작하기 (로그인, 로그아웃과 권한) (2)

이전글 [Spring Security] 스프링 시큐리티 이해하기 (1)Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야

tmd8633.tistory.com

 

3-3. rememberMe()

  • 세션 만료 후에도 로그인 유지 기능
http.rememberMe(remember -> remember
    .rememberMeServices(rememberMeServices)
)
// 또는

http.rememberMe(remember -> remember
    .key("uniqueAndSecret")
    .tokenValiditySeconds(86400)
    .rememberMeParameter("remember-me")
    .rememberMeCookieName("remember-me")
);

 

 

[Spring Security] 스프링 시큐리티 RememberMe (3)

Remember-Me란?Remember-Me는 사용자가 브라우저를 닫았다가 다시 열어도 로그인 상태를 유지하는 기능입니다. 쿠키를 사용하여 구현되며, Spring Security는 두 가지 구현 방식을 제공합니다.  Simple Hash-B

tmd8633.tistory.com

로그인 폼에서 "로그인 정보 저장" 이런식으로 체크박스되어있는 그 기능에 대한 설명입니다. 아무래도 토큰 형식이다보니까 중요한 작업(결제, 정보수정 등) 에서는 재인증을 요구하는 등의 보안에 신경써야하는 기능입니다. 토큰은 DB에 저장하여 관리하는 것을 권장합니다.

 

 

3-4. passwordManagement()

 

  • 비밀번호 변경 페이지 설정
  • 비밀번호 정책 적용
http.passwordManagement(password -> password
    .changePasswordPage("/change-password")       // 비밀번호 변경 페이지
);

 

 

이 기능은 아무리 찾아봐도 잘 모르겠어서 아래 내용이 사실이 아닐 수 있습니다. 주의해서 읽어주세요

 

비밀번호 변경 페이지에 접속하게 되면 다음과 같은 헤더가 추가된다고합니다.

Change-Password: /change-password

별다른 기능은 없는것같습니다. 혹시라도 누가 이 글을 읽고 passwordManagement 에 대해서 아신다면 댓글로 알려주세요 저도 알고싶어요

 

 

4. 세션관리

4-1. sessionManagerment()

  • 세션 정책 설정
  • 동시 세션 제어 등
http.sessionManagement(session -> session
    .invalidSessionUrl("/login?invalid")
    .maximumSessions(1)
    .maxSessionsPreventsLogin(false)
    .expiredUrl("/login?expired")
);

 

 

[Spring Security] 스프링 시큐리티 SessionManagement (5)

1. Session Management란?Spring Security의 세션 관리는 사용자의 세션을 생성, 유지, 파괴하는 전반적인 프로세스를 관리합니다. 이는 보안과 사용자 경험 모두에 중요한 영향을 미칩니다. 또한 JWT이나 Se

tmd8633.tistory.com

 

 

4-2. securityContext()

  • 보안 컨텍스트 설정
  • SecurityContext 저장 방식 설정
http.securityContext(context -> context
    .requireExplicitSave(true)
    .securityContextRepository(securityContextRepository)  // 저장소 설정
);

 

SecurityContext의 관리 방식을 설정하는 메서드입니다.

 

 

Custom

@Component
public class CustomSecurityContextRepository implements SecurityContextRepository {
    
    @Override
    public SecurityContext loadContext(HttpRequestResponseHolder holder) {
        HttpServletRequest request = holder.getRequest();
        // 요청에서 SecurityContext 로드 로직
        return SecurityContextHolder.createEmptyContext();
    }
    
    @Override
    public void saveContext(SecurityContext context, 
                          HttpServletRequest request,
                          HttpServletResponse response) {
        // SecurityContext 저장 로직
    }
    
    @Override
    public boolean containsContext(HttpServletRequest request) {
        // SecurityContext 존재 여부 확인
        return false;
    }
}

 

 

세션 기반 저장소 (Default)

http.securityContext(context -> context
    .securityContextRepository(new HttpSessionSecurityContextRepository())
);

 

다중 연결 저장소

http.securityContext(context -> context
    .securityContextRepository(new DelegatingSecurityContextRepository(
        new RequestAttributeSecurityContextRepository(),
        new HttpSessionSecurityContextRepository()
    ))
);

 

 

Stateless

http.securityContext(context -> context
    .securityContextRepository(new NullSecurityContextRepository())
);

 

SecurityContext를 저장하지 않습니다. JWT 토큰 기반 인증에서 사용됩니다.

sessionManagement에서

.sessionCreationPolicy(SessionCreationPolicy.STATELESS)

설정을 하면 NullSecurityContextRepository 가 생성됩니다. 그러므로 저장소를 명시적으로 작성하지 않아도 된다는 얘기입니다.

 

// SessionManagementConfigurer.init(H http)
boolean stateless = this.isStateless();
if (securityContextRepository == null) {
    if (stateless) {
        this.sessionManagementSecurityContextRepository = new NullSecurityContextRepository();
    } else {
        // ... 생략
    }
} else {
    // ... 생략
}

// ... 생략

private boolean isStateless() {
    SessionCreationPolicy sessionPolicy = this.getSessionCreationPolicy();
    return SessionCreationPolicy.STATELESS == sessionPolicy;
}

 

요청 속성 저장소

http.securityContext(context -> context
    .securityContextRepository(new RequestAttributeSecurityContextRepository())
);

 

SecurityContext를 요청 속성에 저장하고 단일 요청범위 내에서만 유효합니다. 주로 비동기 요청 처리에서 사용한다고합니다.

사실 이 부분은 공부안해봐서 잘 모르겠습니다.

 

 

 

5. OAuth2

5-1. oauth2Login()

  • OAuth 2.0 로그인 설정
  • 소셜 로그인
http.oauth2Login(oauth2 -> oauth2
    .loginPage("/oauth2/login")
    .defaultSuccessUrl("/home")
    .userInfoEndpoint()
    .userService(customOAuth2UserService));
 

[Spring Security][OAuth2] OAuth2 Log In 소셜로그인 구현 (1)

OAuth2Spring Security의 OAuth 2.0 지원은 크게 두 가지 주요 기능으로 구성됩니다:OAuth2 Resource ServerOAuth2 ClientOAuth2 Log In추가로 OAuth2 Log In이라는 특별한 기능이 있는데, 이는 독립적으로 존재할 수 없으

tmd8633.tistory.com

 

 

5-2. oauth2Client()

  • OAuth 2.0 클라이언트 설정
  • 외부 OAuth 2.0 리소스 접근
http.oauth2Client(client -> client
    .clientRegistrationRepository(clientRegistrationRepository)
    .authorizedClientService(authorizedClientService));

 

 

 

5-3. oauth2ResourceServer()

  • OAuth 2.0 리소스 서버 설정
  • JWT 토큰 검증
http.oauth2ResourceServer(server -> server
    .accessDeniedHandler(new CustomAccessDeniedHandler())
    .authenticationEntryPoint(new CustomEntryPoint())
    .jwt(jwt -> jwt
        .jwtAuthenticationConverter(jwtAuthenticationConverter)
    )
);

 

JWT 관련해서 작성하고 있는 글이 있습니다. 업로드되는대로 업데이트 할겁니다.

 

 

6. 엔터프라이즈 보안

6-1. oidcLogout()

  • OpenID Connect 로그아웃 설정
http.oauth2Login(oauth2 -> oauth2
    .clientRegistrationRepository(clientRegistrationRepository));
    
http.oidcLogout(oidc -> oidc
    .clientRegistrationRepository(clientRegistrationRepository)
    .backChannel(logout -> logout
        .logoutUri("/api/logout")
        .logoutHandler((request, response, authentication) -> {

        })
    )

);

 

OIDC 설정은 아래 게시글에서 보시면 됩니다.

 

 

[Spring Security][OAuth2] OpenID Connect(OIDC) 구현 (2)

개요지난 글에서 OAuth2를 이용해서 로그인하는 방법을 알아보았습니다. 이번에는 OpenID Connect 를 이용해서 로그인을 구현해보도록 하겠습니다.  OIDC란?OpenID Connect(OIDC)는 OAuth 2.0 프로토콜 위에

tmd8633.tistory.com

 

 

6-2. saml2Login()

  • SAML 2.0 인증 설정
  • SSO(Single Sign On) 구현
http.saml2Login(saml2 -> saml2
    .relyingPartyRegistrationRepository(relyingPartyRegistrationRepository));

이 부분도 넘어가겠습니다 아직 여긴 공부 안했습니다.


 

7. 요청 처리

7-1. securityMatcher()

  • Security 기반 URL 접근 제어
http.securityMatcher("/**") // 해당 URL만 아래 Security 설정 적용
    .authorizeHttpRequests(request -> request
        .anyRequest().permitAll()
    )

 

REST API, WEB 환경에서 각각 Security 설정을 부여하고 싶은 경우

 

@Bean
    @Order(1)
    SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {

        http.securityMatcher("/api/**")
            .authorizeHttpRequests(request -> request
                .requestMatchers("/api/user").hasAnyRole(Role.USER.name(), Role.ADMIN.name())
                .requestMatchers("/api/admin").hasRole(Role.ADMIN.name())
                .anyRequest().permitAll()
            );

        return http.build();
    }

    @Bean
    @Order(2)
    SecurityFilterChain webSecurityFilterChain(HttpSecurity http, RememberMeServices rememberMeServices) throws Exception {

        http.securityMatcher("/**")
            .authorizeHttpRequests(request -> request
                .requestMatchers("/user/**").hasAnyRole(Role.USER.name(), Role.ADMIN.name())
                .requestMatchers("/admin/**").hasRole(Role.ADMIN.name())
                .anyRequest().permitAll()
            );

        return http.build();
    }

securityMatcher 경로가 세부적일 수록 Order 우선순위가 높아야합니다.

 

// 이러면 절대안됨!!! 
http.securityMatcher("/api/**")
    .authorizeHttpRequests(request -> request
        .requestMatchers("/api/user").hasAnyRole(Role.USER.name(), Role.ADMIN.name())
        .requestMatchers("/api/admin").hasRole(Role.ADMIN.name())
        .anyRequest().permitAll()
    )
    .securityMatcher("/**")
    .authorizeHttpRequests(request -> request
        .requestMatchers("/user/**").hasAnyRole(Role.USER.name(), Role.ADMIN.name())
        .requestMatchers("/admin/**").hasRole(Role.ADMIN.name())
        .anyRequest().permitAll()
    );

이어 붙힐 수 있다고 이렇게 붙히면 안됩니다. 반드시 SecurityFilterChain으로 연결해서 두개의 Filter로 사용해야 됩니다.

 

 

7-2. authorizeHttpRequests()

  • URL 기반 접근 제어
  • 권한별 리소스 접근 설정
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .requestMatchers("/user/**").hasRole("USER")
    .anyRequest().authenticated());

아래 게시물 권한설정 파트 보시면 됩니다.

 

 

[Spring Security] 스프링 시큐리티 시작하기 (로그인, 로그아웃과 권한) (2)

이전글 [Spring Security] 스프링 시큐리티 이해하기 (1)Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야

tmd8633.tistory.com

 

 

8. 예외처리

8-1. exceptionHandling()

  • 보안 예외 처리
  • 인증 / 인가 실패 처리
http.exceptionHandling(exception -> exception
    .authenticationEntryPoint(customAuthEntryPoint)
    .accessDeniedHandler(customAccessDeniedHandler));

 

아래 글에서 ExceptionHandling에 대해서 다뤘습니다.

 

 

[Spring Security] 스프링 시큐리티 Anonymous과 ExceptionHandling (4)

Anonymous 인증이란?Spring Security의 Anonymous 인증은 인증되지 않은 사용자(로그인하지 않은 사용자)를 처리하는 메커니즘입니다. 인증되지 않은 요청에 대해 AnonymousAuthenticationToken을 생성하여 보안

tmd8633.tistory.com

 

 

9. 기타 설정

9-1. requestCache()

  • 요청 캐시 설정
  • 인증 후 리다이렉트 요청 복원 기능
http.requestCache(cache -> cache
    .requestCache(new NullRequestCache()));

리다이렉트 요청에 대한 정보를 저장하는 곳을 설정하는 부분입니다. 설정은 securityContext() 부분과 비슷한 점이 많습니다.

 

// SessionManagementConfigurer.init(H http)

RequestCache requestCache = (RequestCache)http.getSharedObject(RequestCache.class);
if (requestCache == null && stateless) {
    http.setSharedObject(RequestCache.class, new NullRequestCache());
}

// .. 생략

SessionManagement STATELESS 설정을 하면 알아서 NullRequestCache를 넣어주는걸 볼 수 있습니다. Session 을 사용한다면 설정하지 않아도되고, API를 사용해서 Session Stateless 하려면 sessionManagement에서 STATELESS 설정만 해주면 자동으로 NullRequestCache가 설정되니까 별로 신경안써도 되는것 같습니다.

 

 

9-2. portMapper()

  • HTTP/HTTPS 포트 매핑
  • 보안 채널 전환
http.portMapper(ports -> ports
    .http(8080).mapsTo(8443)    // 8080 -> 8443
    .http(80).mapsTo(443)       // 80 -> 443
);



10. Filter

10-1. addFilter

http.addFilterBefore(new CustomFilter(), UsernamePasswordAuthenticationFilter.class)  // 특정 필터 이전에 추가
    .addFilterAfter(new CustomFilter(), UsernamePasswordAuthenticationFilter.class)   // 특정 필터 이후에 추가
    .addFilterAt(new CustomFilter(), UsernamePasswordAuthenticationFilter.class);     // 특정 필터 위치에 추가

 

자신이 만든 필터를 Security Filter에 추가 시킬 수 있습니다. 그냥 addFilter(new CustomFilter()) 이렇게도 사용할 수는 있지만 특정 필터를 기준으로 Filter를 추가하는게 명확한 필터 순서를 제어할 수 있기때문에 Before, After, At을 사용하는것을 권장합니다.

 

 

마무리

후 이 글 쓰는데 오래걸렸습니다. Spring Security 에 대해서 계속 공부하고는 있는데 공식문서만 들어가기만 하면 정신이 혼미해지네요. 내용도 많고 서로 얽혀있는게 많아서 이해하면서 읽기가 쉽지가 않았습니다. 그래서 중간점검이나 할겸 조금 정리해봤는데 틀린 부분도 있을 겁니다. 그러니 걸러가면서 읽어주시기 바랍니다.

개요

지난 글에서 OAuth2를 이용해서 로그인하는 방법을 알아보았습니다. 이번에는 OpenID Connect 를 이용해서 로그인을 구현해보도록 하겠습니다.

 

 

OIDC란?

OpenID Connect(OIDC)는 OAuth 2.0 프로토콜 위에 구축된 인증 레이어입니다. OAuth 2.0이 권한 부여(Authorization)에 중점을 둔다면, OIDC는 여기에 인증(Authentication) 기능을 추가하여 보다 완벽한 ID 관리 솔루션을 제공합니다.

 

 

OIDC의 흐름입니다. 여기서 주목해야할 점은 ID token입니다. OAuth2 방식과 다른점은 유저 정보를 포함하는지 여부라고 할 수 있습니다. 유저정보를 포함하는 것만으로 요청회수를 반으로 줄일 수 있습니다.

OAuth2 OIDC
Access Token 발급 요청 Access Token, ID Token 발급 요청
Access Token 발급 Access Token, ID Token 발급
유저 프로필 발급 요청  
유저 프로필 발급  

 

 

ID token 예시

{
  "iss": "https://auth.example.com",
  "sub": "user123",
  "aud": "client_id",
  "exp": 1516239022,
  "iat": 1516235422,
  "auth_time": 1516235422,
  "nonce": "n-0S6_WzA2Mj",
  "name": "John Doe",
  "email": "johndoe@example.com"
}

 

  • iss : 토큰 발급자
  • sub : 리소스 내 유저 식별자
  • aud : Client Id
  • exp : 토큰 만료시간
  • lat : 토큰 발급 시간

 

 

 

 

구현

먼저 카카오와 구글에서 OpenID Connect를 사용할 수 있도록 해줍시다.

 

 

카카오 로그인을 클릭하고 OpenID Connect 활성화 상태를 ON으로 바꿔줍니다.

 

 

 

구글 범위 설정에서 openid를 추가해주고

application.yaml scope에도 openid를 추가해줍시다.

 

 

OidcUserDetails

public class OidcUserDetails extends DefaultOidcUser {

    private final User user;

    public OidcUserDetails(User user, Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken, OidcUserInfo userInfo, String nameAttributeKey) {
        super(authorities, idToken, userInfo, nameAttributeKey);
        this.user = user;
    }

}

 

 

@Service
@RequiredArgsConstructor
public class CustomOidcUserService extends OidcUserService {

    private final UserDB db;

    @Override
    public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        Provider provider = Provider.findByProvider(userRequest.getClientRegistration().getClientName());
        DefaultOAuth2User convert = provider.convert(oAuth2User);

        String username = provider + "_" + convert.getProviderId();

        OidcUser oidcUser = super.loadUser(userRequest);
        String userNameAttributeName = userRequest
            .getClientRegistration()
            .getProviderDetails()
            .getUserInfoEndpoint()
            .getUserNameAttributeName();

        User user = db.findByUsername(username)
            .orElseGet(() -> db.save(User.builder()
                .username(username)
                .role(Role.USER)
                .name(convert.getName())
                .provider(provider)
                .providerId(convert.getProviderId())
                .build())
            );
        return new OidcUserDetails(
            user,
            Set.of(new SimpleGrantedAuthority(Role.USER.getRoleName())),
            oidcUser.getIdToken(),
            oidcUser.getUserInfo(),
            userNameAttributeName);
    }
}

 

이전 OAuth2UserService와 매우 흡사합니다. 

 

 

SecurityConfig

        .oauth2Login(login -> login
            .defaultSuccessUrl("/user?social")
            .userInfoEndpoint(userInfo -> userInfo
                .oidcUserService(customOidcUserService)
            )
            .failureHandler((request, response, exception) -> {
                exception.printStackTrace();
                response.sendRedirect("/login?error");
            })
        )

.userService() 를 지우고 .oidcUserService에 OidcUserService를 넣어줍시다.

 

 

 

 

OAuth2AuthorizedClient

@Entity
@Table(name = "oauth2_authorized_client")
@IdClass(OAuth2AuthorizedClientId.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OAuth2AuthorizedClient {

    @Id
    private String clientRegistrationId;

    @Id
    private String principalName;

    private String accessTokenType;

    @Lob
    private byte[] accessTokenValue;

    private LocalDateTime accessTokenIssuedAt;

    private LocalDateTime accessTokenExpiresAt;

    private String accessTokenScopes;

    @Lob
    private byte[] refreshTokenValue;

    private LocalDateTime refreshTokenIssuedAt;

    @Column(columnDefinition = "timestamp default current_timestamp")
    private LocalDateTime createdAt;
    
}

 

@Data
@NoArgsConstructor
@AllArgsConstructor
public class OAuth2AuthorizedClientId implements Serializable {
    private String clientRegistrationId;
    private String principalName;
}

 

실제로 잘 작동하는지 확인해보도록하겠습니다. 위와 같이 테이블을 만들어줍니다. 이 스키마는 공식문서에서 제공하는 데이터를 기반으로 작성되었습니다.

 

Security Database Schema :: Spring Security

The standard JDBC implementation of the UserDetailsService (JdbcDaoImpl) requires tables to load the password, account status (enabled or disabled) and a list of authorities (roles) for the user. You can use these as a guideline for defining the schema for

docs.spring.io

 

@Configuration
public class OAuth2Config {

    @Bean
    OAuth2AuthorizedClientService authorizedClientService(ClientRegistrationRepository clientRegistrationRepository, JdbcTemplate jdbcTemplate) {
        return new JdbcOAuth2AuthorizedClientService(jdbcTemplate, clientRegistrationRepository);
    }
}

OAuth2AuthorizedCLientService를 JdbcOAuth2AuthorizedClientService로 넣어줍니다.

 

 

 

결과

 

이제 로그인을 해주면 잘 뜹니다! 만약 Google 로그인 시에 RefreshToken 값이 비어있다면

        provider:
          google:
            authorization-uri: https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&prompt=consent

access_type=offline, prompt=consent를 추가하면 refreshToken도 보내줄겁니다.

 

 

 

 

결론

  OAuth2 OIDC
중점 인가(Authorization) 인증(Authentication)
내용 AccessToken AccessToken + ID Token
용도 API 이용 권한 SSO(Single Sign On) 통합 로그인

 

OAuth2

Spring Security의 OAuth 2.0 지원은 크게 두 가지 주요 기능으로 구성됩니다:

  1. OAuth2 Resource Server
  2. OAuth2 Client
    • OAuth2 Log In

추가로 OAuth2 Log In이라는 특별한 기능이 있는데, 이는 독립적으로 존재할 수 없으며 OAuth2 Client 기능을 기반으로 동작합니다. 이러한 기능들은 OAuth 2.0 Authorization Framework에서 정의하는 리소스 서버와 클라이언트 역할을 담당하며, 인증 서버 역할은 Spring Authorization Server라는 별도 프로젝트를 통해 제공됩니다.

 

OAuth2 문서도 방대하기 때문에 하나씩 차근차근 알아가 보도록 하겠습니다.

 

 

 

시작하기 전에

지금까지 사용하던 InMemoryDB를 더는 사용하지 않고 MySQL을 사용합니다.

application.properties -> application.yaml 을 사용합니다.

 

@EqualsAndHashCode(of = "id")
@AllArgsConstructor @NoArgsConstructor
@Builder
@Entity
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;
    private String password;
    @Enumerated(EnumType.STRING)
    private Role role;
    private String name;

    @Enumerated(EnumType.STRING)
    private Provider provider;
    private String providerId;

}

변경된 User

Provider에 대해선 아래에 나옵니다.

 

 

// 신규
@AllArgsConstructor
@Getter
public enum Role {

    USER("ROLE_USER"),
    ADMIN("ROLE_ADMIN")
    ;
    private final String roleName;
}

// SecurityConfig

        http.authorizeHttpRequests(request -> request
            .requestMatchers("/user/**").hasAnyRole(Role.USER.name(), Role.ADMIN.name())
            .requestMatchers("/admin/**").hasRole(Role.ADMIN.name())
            .requestMatchers("/join", "/signup").anonymous()
            .anyRequest().permitAll()
        );
        
 // UserDetails
 
     @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(user.getRole().getRoleName()));
    }

변경된 권한 인증방법

 

 

Google Cloud Platform

사용자 인증정보에서 승인된 리디렉션 URI를
http://localhost:8080/login/oauth2/code/google

로 설정해줍니다.

 

의존성 주입

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

OAuth2 Log In은 OAuth2 Client 기반으로 동작하는 기능입니다. 따라서 OAuth2 Client 를 받아줍니다.

 

 

 

Entity 설정

이번에 적용할건 Goole, Kakao 소셜로그인 입니다. 먼저 Provider를 정의해줍니다.

 

public enum Provider {

    GOOGLE,
    KAKAO;

}

 

그리고 User Entity에 넣어줍니다.

 

 

application.yaml

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: GOOGLE_CLIENT_ID
            client-secret: GOOGLE_SECRET
            scope:
              - email
              - profile

 

구글로그인 설정할때 받아둔 Client ID와 Secret Key를 넣어줍니다. scope는 

 

범위에서 설정한 범위를 말하는겁니다.

 

 

OAuth2UserService

UserDetailsService와 구현방법이 동일합니다.

@Service
@RequiredArgsConstructor
public class PrincipalOAuth2UserService extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        return super.loadUser(userRequest);
    }
    
}

DefaultOAuth2UserService를 상속받아줍니다.

loadUser에서 소셜로그인을 시도한 유저의 데이터를 확인하고 로그인 처리를 해주면 됩니다.

 

@Service
@RequiredArgsConstructor
public class PrincipalOAuth2UserService extends DefaultOAuth2UserService {

    private final UserDB db;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        System.out.println(oAuth2User.getAttributes());

        Provider provider = Provider.GOOGLE;
        String id = oAuth2User.getName();

        String username = provider + "_" + id;

        User user = db.findByUsername(username)
            .orElseGet(() -> db.save(User.builder()
                .username(username)
                .role(Role.USER)
                .name(convert.name())
                .provider(provider)
                .providerId(id)
                .build())
            );
        return new PrincipalDetails(user);
    }
}

 

먼저 Google 로그인부터 구현해보겠습니다.

  1. super.loadUser 에서 소셜에서 인증된 사용자를 받아옵니다.
  2. Provier + "_" + id 를 username으로 사용합니다.
  3. DB에서 username으로 유저를 찾습니다. 없으면 db에 새로운 유저를 넣어줍니다.
  4. PrincipalDetails로 반환합니다.

여기에서 id는 소셜의 고유번호입니다. id가 1234134513 인 경우, GOOGLE_1234134513 이 username이 됩니다.

회원가입에 대한 추가 로직이 존재하면 코드를 변경하여 추가 정보를 받도록 변경하면됩니다.

 

 

 

SecurityConfig

            http.oauth2Login(login -> login
                .loginPage("/join")
                .defaultSuccessUrl("/user")
                .userInfoEndpoint(endPoint -> endPoint
                    .userService(principalOAuth2UserService)
                )
            )

userInfoEndPoint에서 userService에 위에서 만든 PrincipalOAuth2UserService를 넣어줍니다.

 

 

HTML

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form th:action="@{/join}" th:method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <label>
            <input type="checkbox" name="remember-me" /> Remember Me
        </label>
        <input type="submit" value="로그인">
    </form>
    <a href="/oauth2/authorization/google">구글로그인</a>
    <a href="/oauth2/authorization/kakao">카카오로그인</a>
</body>
</html>

 

/oauth2/authorization/google

/oauth2/authorization/kakao

를 추가해줍니다. 카카오는 이후에 개발하겠습니다.

 

 

결과

이제 서버를 키고 구글로그인을 눌러 로그인을 시도해봅시다.

잘 작동합니다.

 

뭔가 이상합니다. 저희는 기존코드에는 손도 안대고 security 에 oAuth2Login에 Service만 끼워넣었을뿐인데 기능이 동작합니다. 

그리고 '/oauth2/authorization/google'나 redirect URI인 '/login/oauth2/code/google' 부분은 Controller도 건드리지 않았는데 어떻게 동작한걸까요?

 

 

 

 

코드 파보기

 

CommonOAuth2Provider

지금 이 글을 읽고 있는 시점에 얼마나 많은 사람이 Google 소셜로그인을 개발했을까요? 수 백만명은 될겁니다. 그렇기에 Spring Security에서 가장 많이 사용하는 소셜은 미리 구현해놓은 겁니다.

 

public enum CommonOAuth2Provider {
    GOOGLE {
        public ClientRegistration.Builder getBuilder(String registrationId) {
            ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
            builder.scope(new String[]{"openid", "profile", "email"});
            builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
            builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
            builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
            builder.issuerUri("https://accounts.google.com");
            builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
            builder.userNameAttributeName("sub");
            builder.clientName("Google");
            return builder;
        }
    },
    GITHUB {
        public ClientRegistration.Builder getBuilder(String registrationId) {
            ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
            builder.scope(new String[]{"read:user"});
            builder.authorizationUri("https://github.com/login/oauth/authorize");
            builder.tokenUri("https://github.com/login/oauth/access_token");
            builder.userInfoUri("https://api.github.com/user");
            builder.userNameAttributeName("id");
            builder.clientName("GitHub");
            return builder;
        }
    },
    FACEBOOK {
        public ClientRegistration.Builder getBuilder(String registrationId) {
            ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_POST, "{baseUrl}/{action}/oauth2/code/{registrationId}");
            builder.scope(new String[]{"public_profile", "email"});
            builder.authorizationUri("https://www.facebook.com/v2.8/dialog/oauth");
            builder.tokenUri("https://graph.facebook.com/v2.8/oauth/access_token");
            builder.userInfoUri("https://graph.facebook.com/me?fields=id,name,email");
            builder.userNameAttributeName("id");
            builder.clientName("Facebook");
            return builder;
        }
    },
    OKTA {
        public ClientRegistration.Builder getBuilder(String registrationId) {
            ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
            builder.scope(new String[]{"openid", "profile", "email"});
            builder.userNameAttributeName("sub");
            builder.clientName("Okta");
            return builder;
        }
    };

    private static final String DEFAULT_REDIRECT_URL = "{baseUrl}/{action}/oauth2/code/{registrationId}";

    private CommonOAuth2Provider() {
    }

    protected final ClientRegistration.Builder getBuilder(String registrationId, ClientAuthenticationMethod method, String redirectUri) {
        ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(registrationId);
        builder.clientAuthenticationMethod(method);
        builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE);
        builder.redirectUri(redirectUri);
        return builder;
    }

    public abstract ClientRegistration.Builder getBuilder(String registrationId);
}

 

GOOGLE, GITHUB, FACEBOOK, OKTA 가 보입니다.

redirect url에 "{baseUrl}/{action}/oauth2/code/{registrationId}" 라고 적혀있네요. action에는 login이 들어가면 우리가 구글 클라우드 플랫폼에서 설정한 그 url과 일치합니다.

 

OAuth2LoginAuthenticationFIlter

public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    public static final String DEFAULT_FILTER_PROCESSES_URI = "/login/oauth2/code/*";
    private static final String AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE = "authorization_request_not_found";
    // ... 생략
}

 

 

OAuth2AuthorizationRequestRedirectFilter

public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter {
    public static final String DEFAULT_AUTHORIZATION_REQUEST_BASE_URI = "/oauth2/authorization";
    private final ThrowableAnalyzer throwableAnalyzer;
    // ... 생략
}

 

필터에서 해당 URI를 Default로 가지고있습니다. 그래서 우리가 구현하지 않고도 동작한거죠.

 

그럼 CommonOAuth2Provider에 없는 카카오나 네이버 같은 소셜은 어떻게 해야할까요? 처음부터 전부 다 구현해야할까요?

아닙니다. 쉽게 확장할 수 있습니다. 그건 아래에서 같이 따라해보면서 알아보도록 하겠습니다.

 

 

경로 설정

위의 Default 경로를 변경하고 싶다면 아래와 같이 baseUri를 설정하면 됩니다.

 

    http.oauth2Login(oauth2 -> oauth2
        .loginPage("/login/oauth2")
        ...
        .authorizationEndpoint(authorization -> authorization
            .baseUri("/login/oauth2/authorization")
            ...
        )
    );

접속 경로를 변경하고싶은 경우 authorizationEndPoint baseUri를 변경

 

    http.oauth2Login(oauth2 -> oauth2
        .redirectionEndpoint(redirection -> redirection
            .baseUri("/login/oauth2/callback/*")
            ...
        )
    );

Redirect Url을 변경하고싶은 경우 redirectionEndpoint baseUrl를 변경하고,

spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-name: 'kakao'
            redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'

redirect-uri를 변경하면 됩니다.

카카오 로그인

redirect URI가 위와 같이 잘 설정되어있는지 확인합니다.

 

application.yaml

spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-name: 'kakao'
            client-id: KAKAO_REST_API_KEY
            redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            scope:
              - profile_nickname
              - profile_image
              - account_email

        provider:
          kakao:
            authorization-uri: 'https://kauth.kakao.com/oauth/authorize'
            token-uri: 'https://kauth.kakao.com/oauth/token'
            user-info-uri: 'https://kapi.kakao.com/v2/user/me'
            user-name-attribute: 'id'

 

카카오 REST API 키를 client-id에 넣어줍니다. scope는 카카오 로그인 동의항목에서 선택한 ID를 넣어주면됩니다.

저는 프로필, 이름, 이메일을 가져오도록 했습니다.

user-name-attribute는 OAuth2User 객체에 name에 들어가는 파라미터의 Key값입니다.

 

 

데이터 구조

먼저 PrincipalOAuth2UserService에 loadUser부터 수정해주어야 합니다. GOOGLE로 하드 코딩된걸 수정할겁니다.

근데 여기서 약간의 문제가 있습니다. Google에 ID 값은 'sub'이고 Kakao에 ID 값은 'id' 인 부분은 user-name-attribute로 통일했기때문에 getName()으로 providerId를 가져오는데 문제가 되지 않지만 이름, 사진, 이메일은 구글과 카카오의 데이터 구조가 완전히 다릅니다. 따라서 converter 해줄 객체가 필요합니다.

 

@Builder
public record DefaultOAuth2User(String providerId, 
                                String email, 
                                String name, 
                                String picture) {
}

매핑될 객체를 하나 만들어주고,

 

@Getter
@AllArgsConstructor
public enum Provider {

    GOOGLE(user -> DefaultOAuth2User.builder()
        .providerId(user.getName())
        .email(user.getAttribute("email"))
        .name(user.getAttribute("name"))
        .picture(user.getAttribute("picture"))
        .build()),
    
    KAKAO(user -> {
        Map<String, Object> kakaoAccount = user.getAttribute("kakao_account");
        Map<String, Object> attribute = (Map<String, Object>) kakaoAccount.get("profile");

        return DefaultOAuth2User.builder()
            .providerId(user.getName())
            .email((String) kakaoAccount.get("email"))
            .name((String) attribute.get("nickname"))
            .picture((String) attribute.get("profile_image_url"))
            .build();
    });

    private final Function<OAuth2User, DefaultOAuth2User> converter;

    public static Provider findByProvider(String clientName) {
        return Provider.valueOf(clientName.toUpperCase());
    }

    public DefaultOAuth2User convert(OAuth2User user) {
        return converter.apply(user);
    }

}

 

Provider enum객체를 활용해서 다음과 같이 매핑해주었습니다. convert로 객체를 변환해주는 역할을 하게 되었습니다.

 

사실 위 코드가 마음에 안들어서

public sealed interface OAuth2Converter permits
    OAuth2GoogleConverter,
    OAuth2KakaoConverter {

    DefaultOAuth2User convert(OAuth2User user);
}

public final class OAuth2GoogleConverter implements OAuth2Converter {

    @Override
    public DefaultOAuth2User convert(OAuth2User user) {
        return DefaultOAuth2User.builder()
            .providerId(user.getName())
            .email(user.getAttribute("email"))
            .name(user.getAttribute("name"))
            .picture(user.getAttribute("picture"))
            .build();
    }
}

public final class OAuth2KakaoConverter implements OAuth2Converter {

    @Override
    public DefaultOAuth2User convert(OAuth2User user) {
        Map<String, Object> kakaoAccount = user.getAttribute("kakao_account");
        Map<String, Object> attribute = (Map<String, Object>) kakaoAccount.get("profile");

        return DefaultOAuth2User.builder()
            .providerId(user.getName())
            .email((String) kakaoAccount.get("email"))
            .name((String) attribute.get("nickname"))
            .picture((String) attribute.get("profile_image_url"))
            .build();
    }
}
@Getter
@AllArgsConstructor
public enum Provider {

    GOOGLE(new OAuth2GoogleConverter()),
    KAKAO(new OAuth2KakaoConverter());

    private final OAuth2Converter converter;

    public static Provider findByProvider(String clientName) {
        return Provider.valueOf(clientName.toUpperCase());
    }

    public DefaultOAuth2User convert(OAuth2User user) {
        return converter.convert(user);
    }

}

 

이렇게 하니까 좀더 깔끔해졌습니다.

 

 

PrincipalOAuth2UserService

@Service
@RequiredArgsConstructor
public class PrincipalOAuth2UserService extends DefaultOAuth2UserService {

    private final UserDB db;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        Provider provider = Provider.findByProvider(userRequest.getClientRegistration().getClientName());
        DefaultOAuth2User convert = provider.convert(oAuth2User);

        String username = provider + "_" + convert.providerId();

        User user = db.findByUsername(username)
            .orElseGet(() -> db.save(User.builder()
                .username(username)
                .role(Role.USER)
                .name(convert.name())
                .provider(provider)
                .providerId(convert.providerId())
                .build())
            );
        return new PrincipalDetails(user);
    }
}

 

 

결과

이제 테스트해보면 카카오 로그인도 아주 간단하게 연동했습니다. 

 

application.yaml 을 사용하지 않고 추가하는 방법도 있습니다.

@Configuration
public class OAuth2LoginConfig {

	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http
			.authorizeHttpRequests(authorize -> authorize
				.anyRequest().authenticated()
			)
			.oauth2Login(withDefaults());
		return http.build();
	}

	@Bean
	public ClientRegistrationRepository clientRegistrationRepository() {
		return new InMemoryClientRegistrationRepository(this.googleClientRegistration());
	}

	private ClientRegistration googleClientRegistration() {
		return ClientRegistration.withRegistrationId("google")
			.clientId("google-client-id")
			.clientSecret("google-client-secret")
			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
			.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
			.scope("openid", "profile", "email", "address", "phone")
			.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
			.tokenUri("https://www.googleapis.com/oauth2/v4/token")
			.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
			.userNameAttributeName(IdTokenClaimNames.SUB)
			.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
			.clientName("Google")
			.build();
	}
}

 

그런데 key가 노출되기때문에 사용은 안할겁니다. 궁금하시다면 공식문서를 참고해주세요.

https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html

 

 

 

결론

소셜로그인을 추가하는건 생각보다 간단합니다. 혼자 공부하면서 작성한거라 틀린부분이 있을 수 있습니다. 언제든지 지적해주세요.

 

 

 

카카오 로그인

두번째로 카카오 로그인도 준비하겠습니다.

 

 

 

1. 카카오 개발자 접속

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

내 애플리케이션 - 애플리케이션 추가하기 눌러서 애플리케이션을 만들어줍시다.

 

 

 

 

2. REST API 키 보관

애플리케이션에 들어와서 앱 키 메뉴에 들어가보면 REST API 키가 있습니다. 이 키를 보관해줍니다.

 

 

 

3. 플랫폼 설정

 

플랫폼 메뉴에 들어가면 Android, iOS, Web이 있습니다. 도메인을 설정해줍니다.

 

 

 

4. 카카오 로그인 설정

 

카카오 로그인을 들어와서 활성화 해줍니다.

밑에 Redirect URI가 있습니다. 

저는 http://localhost:8080/login/oauth2/code/kakao 이렇게 설정햇습니다.

 

5. 동의항목 설정

카카오 로그인 바로 밑에 동의항목 메뉴가 있습니다. 들어가줍니다.

 

 

서비스에 맞게 가져올 데이터를 선택해줍니다.

 

 

 

 

 

끝!

개요

저번 글에서 SessionManagement 에 대해서 소개하는 글을 적었습니다. 공식문서에 있는 글을 최대한 줄이고 압축해서 글을 쓰려다보니 설명이 부족한 부분도 많았던 것 같습니다. 그래서 두 파트로 나누었고 이번 글에서는 SessionManagement를 어떤식으로 적용하는지 보여드리도록 하겠습니다.

 

 

 

1. 동시 로그인 차단하기

먼저 동시 세션을 제어해서 하나의 로그인한 허용해보도록 하겠습니다.

저는 최대 1개의 세션만 허용하고 새로운 로그인이 발생하면 이전 세션을 만료시키겠습니다. 그리고 세션이 만료가 되면 "/join?expired" 로 이동시키도록 제어해 보겠습니다.

 

 

UserDetails

세션을 1개로 제한한다는 것은 이전 세션과 비교해서 같은세션인지 확인하는 과정이 있어야 가능합니다. 이전 시간에 UserDetails를 구현한 PrincipalDetails 구현체가 있었습니다. 이 PrincipalDetails 의 equals와 hashCode를 override하겠습니다.

 

여기서 한가지 알아야할 것은 PrincipalDetails 객체 자체를 비교하는것은 중요하지 않습니다. 그 안에 있는 User를 비교하는 것이 더 중요합니다. User의 기본키는 교유하기 때문입니다.

 

먼저 User 객체의 equals와 hashCode를 override 하겠습니다.

 

@Getter @Setter
@EqualsAndHashCode(of = "username")
public class User {

    private String username;
    private String password;
    private String role;
    private String name;
}

저는 간단한 개발환경을 위해서 기본키값을 username으로 했지만 실제 DB를 사용하는 환경에서는 id값을 비교하면됩니다.

 

 

lombok을 사용하지 않을 경우

@Getter @Setter
public class User {

    private String username;
    private String password;
    private String role;
    private String name;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(username, user.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(username, username);
    }
}

 

JPA가 기본적으로 id 기반으로 equals/hashCode 제공합니다. 복합키를 사용하거나 또는 명확성을 위해서 객체에서 override해주는 것도 좋은 방법이라고 생각합니다.

 

 

public record PrincipalDetails(User user) implements UserDetails {

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(user.getRole()));
    }

    @Override
    public String getPassword() {
        return user == null ? null : user.getPassword();
    }

    @Override
    public String getUsername() {
        return user == null ? null : user.getUsername();
    }

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

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

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

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

 

 

 

record의 특성을 이해하길 원하신다면 다음 글을 읽어보시길 추천드립니다.

 

 

[JAVA] Record와 Sealed

개요오늘은 Java 14부터 도입된 Record와 Java 15에서 preview로 시작된 Sealed Classes 에 대해서 알아보겠습니다.참고로 Sealed Classes 는 Java 17부터 정식적으로 확정된 것같습니다. JDK 17 Release Notes, Important C

tmd8633.tistory.com

 

 

 

SecurityConfig

        http.sessionManagement(session -> session
            .maximumSessions(1) // 사용자당 세션 최대 1개
            .maxSessionsPreventsLogin(true) // 새로운 로그인 차단
            .expiredUrl("/join?expired") // 세션 만료시 로그인으로 이동
        );

 

기존 세션을 만료시키기 전에 새로운 로그인을 차단하는 기능에 대해서 알아 보겠습니다. 이렇게 설정하고 동시 로그인을 하게되면

 

 

해당 url로 이동하게 됩니다. maxSessionPreventsLogin(true)는 

        http.formLogin(login -> login
                .loginPage("/join")
                .loginProcessingUrl("/join")
                .defaultSuccessUrl("/user")
                .failureUrl("/join?error")
            )

formLogin 에서 로그인 실패로 남게됩니다.

        http.formLogin(login -> login
                .loginPage("/join")
                .loginProcessingUrl("/join")
                .defaultSuccessUrl("/user")
                .failureHandler((request, response, exception) -> {
                    exception.printStackTrace();
                    response.sendRedirect("/join?error");
                })
            )

 

failureHandler에서 printStackTrace를 해보면

org.springframework.security.web.authentication.session.SessionAuthenticationException: Maximum sessions of 1 for this principal exceeded

이렇게 뜨는 것을 확인할 수 있습니다.  따라서 새로운 로그인을 차단하는 기능을 활성화 시킨다면 formLogin failureHandler에서 처리를 해야한다는 뜻입니다.

 

 

자 다시 돌아와서 maxSessionPreventsLogin(false) : 기존 세션 만료 로 돌려놓고 새로운 로그인을 시도해보겠습니다.

 

기존 로그인 했던 페이지에서 새로고침을 해보면 '/join?expired' 로 redirect 되었습니다. 세션 만료가 잘 되었군요. 이제 서버에는 세션은 최대 1개까지만 접속할 수 있게 되었습니다.

 

 

잠깐 SessionRegistry 는 왜 등록하지 않았나?

동시세션을 제어하기 위해서는 SessionRegistry를 등록해서 제어해야합니다. 그런데 위에 코드에는

.sessionRegistry(new SessionRegistryImpl())

이런 코드는 작성하지 않았습니다.

 

결론부터 말하자면 동시세션제어를 활성화할때 Bean에 SessionRegistry를 등록하지 않는다면 내부에서 new SessionRegistryImpl()을 생성해서 넣어줍니다.

 

 

.maximumSessions(1) 을 할때 내부 코드를 보면 ConcurrencyControlConfigurer 를 반환합니다.

    public SessionManagementConfigurer<H>.ConcurrencyControlConfigurer maximumSessions(int maximumSessions) {
        this.maximumSessions = maximumSessions;
        this.propertiesThatRequireImplicitAuthentication.add("maximumSessions = " + maximumSessions);
        return new ConcurrencyControlConfigurer();
    }

 

그 내부를 들어가보면 SessionManagementConfigurer 중첩클래스로 정의 되어있습니다.

 

그리고 getSessionRegistry 를 보면 sessionRegistry == null 이면 sessionRegistryImpl() 을 내부적으로 가지는것을 확인할 수 있습니다.

    // SessionManagementConfigurer 내부
    private SessionRegistry getSessionRegistry(H http) {
        if (this.sessionRegistry == null) {
            this.sessionRegistry = (SessionRegistry)this.getBeanOrNull(SessionRegistry.class);
        }

        if (this.sessionRegistry == null) {
            SessionRegistryImpl sessionRegistry = new SessionRegistryImpl();
            this.registerDelegateApplicationListener(http, sessionRegistry);
            this.sessionRegistry = sessionRegistry;
        }

        return this.sessionRegistry;
    }

 

사용처를 본다면

private ConcurrentSessionFilter createConcurrencyFilter(H http) {
    SessionInformationExpiredStrategy expireStrategy = this.getExpiredSessionStrategy();
    SessionRegistry sessionRegistry = this.getSessionRegistry(http);
    // ... 중략
    return concurrentSessionFilter;
}

private SessionAuthenticationStrategy getSessionAuthenticationStrategy(H http) {
        if (this.sessionAuthenticationStrategy != null) {
            return this.sessionAuthenticationStrategy;
        } else {
            // ... 중략
            if (this.isConcurrentSessionControlEnabled()) {
                SessionRegistry sessionRegistry = this.getSessionRegistry(http);
                // ... 중략
            } else {
                delegateStrategies.add(defaultSessionAuthenticationStrategy);
            }

            this.sessionAuthenticationStrategy = (SessionAuthenticationStrategy)this.postProcess(new CompositeSessionAuthenticationStrategy(delegateStrategies));
            return this.sessionAuthenticationStrategy;
        }
}

 

동시세션필터를 생성할때, SessionAuthenticationStrategy를 가져올때 사용되는것을 볼 수 있습니다.

 

 

2. 세션 생성 정책 설정

        http.sessionManagement(sessionManagement -> sessionManagement
            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)     // 필요한 경우에만 생성
        );

이 값은 변경할 필요가 없을 것같습니다. 그렇기 때문에 생략하도록 하겠습니다.

다만, 이후에 JWT를 다룰때 SessionCreationPolicy.STATELESS(세션을 사용하지 않음)으로 변경할 예정입니다.

 

 

3. 세션 고정 보호 설정

        http.sessionManagement(session -> session
            .sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::changeSessionId)
            .maximumSessions(1) // 사용자당 세션 최대 1개
            .maxSessionsPreventsLogin(false) // 기존 세션 만료
            .expiredUrl("/join?expired") // 세션 만료시 로그인으로 이동
        );

세션 생성 정책과 마찬가지로 changeSessionId 가 기본값이기 때문에 생략하도록 하겠습니다.

 

 

 

4. 세션 타임아웃 설정

유효하지 않은 세션에 대한 설정인데요. 값이 변조되었거나 서버 재구동등 여러 요인으로 인해서 유효하지 않은 세션이 들어왔을때 처리를 담당합니다. 

 

        http.sessionManagement(session -> session
            .invalidSessionUrl("/join?invalid")
            .maximumSessions(1) // 사용자당 세션 최대 1개
            .maxSessionsPreventsLogin(false) // 기존 세션 만료
            .expiredUrl("/join?expired") // 세션 만료시 로그인으로 이동
        );

 

invalidSessionUrl을 "/join?invalid" 로 설정해줍니다.

 

.invalidSessionStrategy(new CustomInvalidSessionStrategy())

이렇게 추가적인 로직이 필요한경우 InvalidSessionStrategy 를 구현해서 넣을 수 있지만 쿠키제거, 세션무효화를 직접 작성해야하는 불편함이 있고 굳이 따로 객체를 만들어서 관리하는 수고로움 보다 JavaScript로 구현하는게 조금더 편한것 같더라구요.

// joinForm.html
<script>
    const urlParams = new URLSearchParams(window.location.search);

    // invalid 파라미터 체크
    if (urlParams.has('invalid')) {
        alert('세션이 유효하지 않습니다');
    }
    // expired 파라미터 체크
    else if (urlParams.has('expired')) {
        alert('세션이 만료되었습니다');
    }
</script>

무엇보다 사용자가 세션만료건 유효하지않건 알아봤자 중요하지도 않고 그게 무슨 뜻인지도 모를것이니까요. 물론 경우에 따라서는 Strategy를 구현해 사용하는 것도 방법입니다.

1. Session Management란?

Spring Security의 세션 관리는 사용자의 세션을 생성, 유지, 파괴하는 전반적인 프로세스를 관리합니다. 이는 보안과 사용자 경험 모두에 중요한 영향을 미칩니다. 또한 JWT이나 Session 관리를 하지않는 API를 사용하기전에 알아두어야 하기때문에 공부해보도록 하겠습니다.

 

 

 

2. Session Creation Policy (세션 생성 정책)

        http.sessionManagement(sessionManagement -> sessionManagement
            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)     // 필요한 경우에만 생성(Default)
        );

 

정책 설명
SessionCreationPolicy.ALWAYS 항상 세션 생성
SessionCreationPolicy.IF_REQUIRED 필요한 경우에만 생성 (Default)
SessionCreationPolicy.NEVER 생성하지 않지만 존재하면 사용
SessionCreationPolicy.STATELESS 세션을 완전히 사용하지 않음 (JWT 등의 토큰 기반 인증에 적합)

 

 

3. Concurrent Session Control (동시 세션 제어)

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}
        http.sessionManagement(sessionManagement -> sessionManagement
            .maximumSessions(1)                     // 사용자당 최대 세션 수
            .maxSessionsPreventsLogin(true)         // true: 새로운 로그인 차단, false: 기존 세션 만료(기본값)
            .expiredUrl("/session-expired")         // 세션 만료시 이동할 URL
        );

 

3-1. HttpSessionEventPublisher

HttpSessionEventPublisher는 세션 생명주기 이벤트를 Spring의 ApplicationContext에 발행하는 역할을 합니다.

@Component
public class SessionEventListener {

    private final Logger logger = LoggerFactory.getLogger(getClass());

    @EventListener
    public void handleSessionCreated(HttpSessionCreatedEvent event) {
        HttpSession session = event.getSession();
        logger.info("새 세션 생성: {}", session.getId());
    }

    @EventListener
    public void handleSessionDestroyed(HttpSessionDestroyedEvent event) {
        HttpSession session = event.getSession();
        logger.info("세션 파괴됨: {}", session.getId());
    }

}

 

세션 모니터링

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}
@Bean
public SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl();
}

        http.sessionManagement(session -> session
            .sessionRegistry(sessionRegistry())
        );
@Component
@Slf4j
@RequiredArgsConstructor
public class SessionEventListener {

    private final SessionRegistry sessionRegistry;

    @EventListener
    public void handleSessionDestroyed(HttpSessionDestroyedEvent event) {
        log.info("세션만료 : {}", event.getSession().getId());
        // 세션이 만료되면 해당 사용자의 세션 정보 정리
        SecurityContext securityContext = (SecurityContext) event.getSession()
            .getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
        if (securityContext != null) {
            sessionRegistry.removeSessionInformation(event.getSession().getId());
        }
    }

    @EventListener
    public void handleSessionCreated(HttpSessionCreatedEvent event) {
        log.info("세션생성 : {}", event.getSession().getId());
        HttpSession session = event.getSession();
        // 세션 생성 시 기본 타임아웃 설정
        session.setMaxInactiveInterval(60 * 30); // 30분
    }

}

 

3-2. 이전 세션 만료 전략

.maximumSessions(1)
.maxSessionsPreventsLogin(false)  // 기본값

 

3-3. 새 로그인 차단 전략

.maximumSessions(1)
.maxSessionsPreventsLogin(true)

 

 

4. Session Fixation Protection (세션 고정 보호)

 

4-1. 세션 고정 보호가 뭘까?

정확히는 세션 고정 공격 보호 (Session Fixation Attack Protection) 입니다. 세션 고정 공격은 악의적인 공격자가 사이트에 접속해서 세션을 생성한 다음 동일한 세션으로 다른 사용자가 접속하도록 유도할 수 있는 잠재적인 공격입니다. (예, 세션 식별자를 매개변수로 포함하는 링크를 전송) Spring Security는 사용자가 로그인할 때 새 세션을 생성하거나 세션 ID를 변경하여 이를 자동으로 보호하는데 이를 설정하는 것이 sessionFixation 설정입니다.

출처 : https://blog.pollra.com/odop-day-76

 

4-2. 세션 고정 보호 설정

        http.sessionManagement(sessionManagement -> sessionManagement
            .sessionFixation(fixation -> fixation
                .changeSessionId() // 세션 ID만 변경 (Servlet 3.1+ 컨테이너 기본값)
            )
        );

 

 

메소드 설명
newSession() 완전히 새로운 세션 생성
migrateSession() 이전 세션 속성을 새 세션으로 복사 (Servlet 3.0이하 Default)
changeSessionId() 세션 ID만 변경 (Servlet 3.1 이상 Default)
none() 보호 비활성화

 

 

 

5. Session Timeout (세션 타임아웃)

세션은 자체적으로 만료되며 Security Context가 제거되도록 해야할 것은 없습니다. 그러므로 세션이 만료된 시점을 감지하고 사용자가 특정 작업을 수행할 수 있도록 엔드포인트를 리디렉션할 수 있게만 해주면됩니다. 이는 invalidSessionUrl 에서 이루어집니다.

 

        http.sessionManagement(session -> session
            .invalidSessionUrl("/invalidSession") // 만료된 세선이 접근할 경우 "/invalidSession" 으로 이동
        );

 

 

        http.sessionManagement(session -> session
            .invalidSessionStrategy(new CustomInvalidSessionStrategy())
        );
        
public class CustomInvalidSessionStrategy implements InvalidSessionStrategy {

    @Override
    public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // Session 무효화
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }

        // JSESSIONID 쿠키 삭제
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("JSESSIONID".equals(cookie.getName())) {
                    cookie.setValue("");
                    cookie.setPath("/");
                    cookie.setMaxAge(0);
                    response.addCookie(cookie);
                    break;
                }
            }
        }
        response.setContentType("text/html;charset=UTF-8");
        PrintWriter out = response.getWriter();
        out.println("<script>alert('세션이 만료되었습니다.');</script>");
        out.flush();
    }

}

 

invalidSessionUrl과 invalidSessionStrategy는 동시에 사용할 수 없습니다. 둘다 정의 되어있다면 invalidSessionStrategy만 적용됩니다. 그냥 세션 만료시 이동을 원할경우 invalidSessionUrl을 사용하고, 복잡한 로직이 추가되어야 한다면 invalidSessionStrategy를 사용하시면 됩니다.

 

 

 

주의사항

로그아웃 시에 쿠키가 제대로 삭제되지 않아 문제가 발생할 수 있습니다.

        http.logout(logout -> logout
            // 모든 쿠키 제거
            .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(ClearSiteDataHeaderWriter.Directive.COOKIES)))
            // 특정 쿠키 제거
            .deleteCookies("JSESSIONID", "remember-me")
        );

이렇게 명시적으로 쿠키를 제거해줍시다.

ClearSiteData를 지원하지 않는 브라우저에서는 정상적으로 동작하지 않을 수 있습니다.

 

 

 

 

6. SessionManagementFilter

sessionManagement는 SessionManagementFilter에서 사용됩니다. 그 역할로는 

 

  • 세션 생성 전략 관리
  • 동시 세션 제어
  • 세션 고정 보호
  • 유효하지 않은 세션 처리

우리가 위에서 알아본 것과 같습니다.

 

6-1. SessionManagementFilter 구조

  • SecurityContextRepository의 내용을 현재 SecurityContextHolder의 내용과 비교 검사
  • 이를 통해 현재 요청 중에 사용자가 인증되었는지 확인
  • 주로 pre-authentication이나 remember-me와 같은 비대화형 인증 메커니즘에서 사용됨

처리 흐름

  • SecurityContextRepository에 Security Context가 있다면 아무 작업도 하지 않음
  • SecurityContextRepository에 context가 없고, SecurityContext가 Authentication 객체를 포함하고 있다면 (익명객체가 아닌)
    • 이전 필터에서 이미 인증되었다고 가정
    • 설정된 SessionAuthenticationStrategy를 실행

미인증 사용자 처리

  • 유효하지 않은 세션 ID가 요청되었는지 확인 (예: 타임아웃으로 인한 유효하지 않은 세션)
  • 설정된 InvalidSessionStrategy가 있다면 이를 실행

 

6-2. SecurityContextRepository

SecurityContextRepository는 SecurityContext를 저장하고 불러오는 역할을 하는 인터페이스입니다. 주로 HTTP 세션에 SecurityContext를 저장하고 검색하는 작업을 담당합니다. 기본객체는 HttpSessionSecurityContextRepository 입니다.

기본 구현

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.securityContext((securityContext) -> securityContext
            .securityContextRepository(new HttpSessionSecurityContextRepository())
        );
        return http.build();
    }
}​


DelegatingSecurityContextRepository 사용 시

@Bean
public SecurityContextRepository securityContextRepository() {
    return new DelegatingSecurityContextRepository(
        new HttpSessionSecurityContextRepository(),
        new RequestAttributeSecurityContextRepository()
    );
}​

 

 

 

7. 보안설정

server.servlet.session.cookie.secure=true
server.servlet.session.cookie.http-only=true

 

server.servlet.session.cookie.secure=true

  • HTTPS를 통해서만 쿠키가 전송되도록 설정
  • HTTP로 요청시 쿠키가 전송되지 않음
  • 중간자 공격(Man-in-the-Middle Attack) 방지
  • 쿠키 탈취 위험 감소

server.servlet.session.cookie.http-only=true

  • JavaScript를 통한 쿠키 접근을 차단
  • XSS(Cross-Site Scripting) 공격 방지
  • 클라이언트 측 스크립트에서 document.cookie로 접근 불가

 

7-1. 추가 설정

# 쿠키 만료 시간 설정
server.servlet.session.timeout=30m

# 쿠키 경로 설정
server.servlet.session.cookie.path=/

# 쿠키 도메인 설정
server.servlet.session.cookie.domain=example.com

# 쿠키 이름 변경
server.servlet.session.cookie.name=MYSESSIONID

# SameSite 설정
server.servlet.session.cookie.same-site=strict

 

 

 

글이 너무 길어져서 여기까지 작성하겠습니다.

SessionManagement 관련된 내용은 어마어마하게 많습니다. 다음글에서는 이 글을 토대로 어떻게 적용하는지에 대해서 알아보도록하겠습니다.

1. CORS란 무엇인가?

CORS(Cross-Origin Resource Sharing)는 웹 브라우저에서 외부 도메인 리소스를 안전하게 요청할 수 있도록 하는 표준 규약입니다. 프론트엔드와 백엔드가 분리하는데 있어 CORS에 대해서 반드시 짚고 넘어가야합니다. 그래서 온르은 CORS에 대해서 공부해보겠습니다.

 

 

 

2. CORS의 필요성

핵심은 외부로부터 리소스를 공유하는 것입니다. 요즘 웹 애플리케이션에 개발에서 백엔드와 프론트엔드를 구분하지 않고 개발하는 곳은 거의 없을 겁니다.

 

  • 프론트엔드와 백엔드의 분리
  • 마이크로서비스 아키텍처 도입
  • 외부 API 활용
  • SPA(Single Page Application) 개발 방식

이러한 상황에서 다른 출처(Origin)의 리소스를 안전하게 요청하고 사용할 수 있어야 했고, 이를 위한 표준이 바로 CORS입니다.

 

 

 

3. Same-Origin Policy

Same-Origin Policy는 웹 브라우저의 기본적인 보안 정책으로, 같은 출처에서만 리소스를 공유할 수 있도록 제한합니다.

출처(Origin)는 다음 세 가지 요소로 결정됩니다:

  • 프로토콜 (http, https)
  • 호스트 (domain)
  • 포트 번호
  • http://example.com/path1, https://example.com/path2 는 프로토콜이 다르므로 다른 출처로 간주됩니다.

 

 

4. CORS 동작 방식

CORS는 HTTP 헤더를 통해 동작합니다. 주요 헤더는 다음과 같습니다:

 

4-1. 요청 헤더

  • Origin: 요청을 보내는 출처
  • Access-Control-Request-Method: 실제 요청에서 사용할 HTTP 메서드
  • Access-Control-Request-Headers: 실제 요청에서 사용할 헤더

4-2. 응답 헤더

  • Access-Control-Allow-Origin: 허용된 출처
  • Access-Control-Allow-Methods: 허용된 HTTP 메서드
  • Access-Control-Allow-Headers: 허용된 헤더
  • Access-Control-Max-Age: 프리플라이트 요청 캐시 시간
  • Access-Control-Allow-Credentials: 인증 정보 포함 여부

 

5. CORS 요청의 종류

 

5-1. Simple Request

  • GET, HEAD, POST 중 하나의 메서드 사용
  • 허용된 헤더만 사용
  • Content-Type이 다음 중 하나:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain

 

5-2. Preflight Request

Simple Request 조건을 만족하지 않는 요청의 경우, 브라우저는 실제 요청 전에 OPTIONS 메서드를 사용한 예비 요청을 보냅니다.

 

5-3. Credentialed Request

인증 정보(쿠키, HTTP 인증)를 포함한 요청입니다.

 

 

 

6. Spring Security CORS 설정

Spring Security CORS 설정에 대해서 알아보겠습니다.

 

 

UrlBasedCorsConfigurationSource apiConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        // 허용할 출처(Origin) 설정
        // https://api.example.com 에서 오는 요청만 허용
        configuration.setAllowedOrigins(List.of("https://api.example.com"));

        configuration.setAllowedOriginPatterns(List.of(
            "https://*.example.com",     // example.com의 모든 서브도메인 허용
            "https://*.example.*.com",   // 더 복잡한 패턴 매칭도 가능
            "http://localhost:[*]"       // 로컬호스트의 모든 포트 허용
        ));

        // 허용할 HTTP 메서드 설정
        // GET과 POST 메서드만 허용 (PUT, DELETE, PATCH 등은 차단됨)
        configuration.setAllowedMethods(List.of("GET","POST"));

        // 허용할 헤더 설정
        // 모두 허용
        configuration.setAllowedHeaders(List.of("*"));

        // 클라이언트에게 노출할 헤더
        configuration.setExposedHeaders(List.of("Authorization"));

        // allowCredentials를 true로 설정할 경우, allowedOrigins에 "*"를 사용할 수 없습니다
        configuration.setAllowCredentials(true);

        // CORS 프리플라이트 요청의 캐시 시간
        configuration.setMaxAge(3600L);

        // URL 패턴별로 CORS 설정을 적용할 수 있는 객체 생성
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

        // 모든 경로("/**")에 대해 위에서 설정한 CORS 설정을 적용
        source.registerCorsConfiguration("/**", configuration);


        return source;
    }

 

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.cors(cors -> cors
            .configurationSource(apiConfigurationSource())
        );
        
        return http.build();
    }

 

6-1. Preflight 란?

configuration.setMaxAge() 에 대해서 간단하게 알아보겠습니다.

  • 브라우저는 실제 요청 전에 OPTIONS 메서드를 사용하여 preflight 요청을 보냅니다
  • 이 요청으로 해당 출처가 안전한지, 어떤 메서드와 헤더가 허용되는지 확인합니다
  • 매 요청마다 preflight를 보내면 성능 저하가 발생할 수 있습니다

6-1-1. maxAge의 역할

// 예시: preflight 응답 헤더
Access-Control-Max-Age: 3600
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type

 

  • 브라우저가 preflight 응답을 캐시하는 시간을 지정
  • 캐시 기간 동안은 동일한 요청에 대해 preflight를 다시 보내지 않음
  • 서버 부하 감소와 성능 향상에 도움

6-1-2. 브라우저별 최대 제한 시간

// 브라우저별 최대 캐시 시간이 다름
Chrome: 2시간 (7200초)
Firefox: 24시간 (86400초)
Safari: 7일

 

 

6-1-3. 장점

  • 서버 부하 감소
  • 네트워크 트래픽 감소
  • 응답 시간 개선

6-1-4. 단점

  • CORS 정책 변경 시 캐시된 정책이 즉시 적용되지 않을 수 있음
  • 브라우저마다 다른 최대 제한으로 일관성 있는 동작을 보장하기 어려움

 

 

6-2. allowCredentials와 allowedOrigins 설정

allowCredentials를 true로 설정하고 allowedOrigins에 "*"를 함께 사용할 수 없는 것은 중요한 보안상의 이유 때문입니다.

 

credentials에는 쿠키, HTTP 인증 토큰과 같은 민감한 인증 정보가 포함됩니다. 그런데 allowedOrigins에 모든 도메인("*")의 접근을 허용한다면 악의적인 웹사이트에서 사용자의 인증정보를 이용해 요청을 보낼 수 있게 됩니다.

 

 

[CS][Spring Security] CSRF란?

CSRF란?CSRF Cross-Site Request Forgery의 약자로 인증된 사용자의 권한을 악용하여 해당 사용자가 의도하지 않은 요청을 웹사이트에 전송하는 공격 기법입니다. 공격자는 사용자가 이미 인증된 상태를

tmd8633.tistory.com

이에 대해서는 CSRF에 대해서 읽어보시기 바랍니다.

 

따라서 브라우저는 이러한 보안 위험을 방지하기 위해 allowCredentials(true)와 allowedOrigins("*")의 조합을 명시적으로 금지하고 있습니다. 이는 웹 보안의 기본 원칙인 "최소 권한의 원칙"을 따르는 것이며, 실수로 인한 보안 취약점 발생을 방지합니다.

 

 

 

7. 자주 발생하는 CORS 에러와 해결 방법

7-1. No 'Access-Control-Allow-Origin' header is present

  • 원인: 서버에서 Access-Control-Allow-Origin 헤더를 설정하지 않음
  • 해결: 서버에서 적절한 CORS 설정 추가

 

7-2. Method not allowed

  • 원인: 허용되지 않은 HTTP 메서드 사용
  • 해결: allowedMethods에 필요한 메서드 추가

 

7-3. Credentials flag is true, but Access-Control-Allow-Credentials is false

  • 원인: 인증 정보를 포함한 요청에 대한 서버 설정 미비
  • 해결: allowCredentials(true) 설정 추가

 

8. 보안 관련 고려사항

8-1. Origin 설정

  • "*" 대신 구체적인 도메인 지정
  • 신뢰할 수 있는 출처만 허용

8-2. 인증 관련

  • allowCredentials(true) 사용 시 구체적인 출처 지정 필요
  • 보안에 민감한 API의 경우 더 엄격한 CORS 정책 적용

8-3. 헤더 설정

  • 필요한 헤더만 허용
  • exposedHeaders 설정 시 최소한의 헤더만 노출

8-4. 캐시 설정

  • maxAge 값을 적절히 설정하여 불필요한 프리플라이트 요청 감소

 

9. 결론

CORS는 현대 웹 개발에서 필수적인 보안 메커니즘입니다. 올바른 CORS 설정은 웹 애플리케이션의 보안과 기능성을 모두 만족시킬 수 있습니다. 각 프로젝트의 요구사항과 보안 정책에 맞게 적절한 CORS 설정을 적용하시기 바랍니다.

'일반 > CS' 카테고리의 다른 글

[CS][Spring Security] CSRF란?  (0) 2024.12.12
Maven Central Repository에 라이브러리 등록하기  (0) 2024.09.19
[CS] MVC 패턴  (0) 2024.04.17
HTTP GET과 POST 차이  (0) 2024.01.25
URI와 URL의 차이점 (Feat : URN)  (0) 2024.01.21

Anonymous 인증이란?

Spring Security의 Anonymous 인증은 인증되지 않은 사용자(로그인하지 않은 사용자)를 처리하는 메커니즘입니다. 인증되지 않은 요청에 대해 AnonymousAuthenticationToken을 생성하여 보안 컨텍스트에 저장합니다. 내용을 길지않으니 빠르게 알아보겠습니다.

 

 

 

사용법

anonymous 설정은 아래와 같습니다.

        http.anonymous(anonymous -> anonymous
            .principal("anonymousUser")      // 익명 사용자의 주체
            .authorities("ROLE_ANONYMOUS")    // 익명 사용자의 권한
        );
        // 비활성화
        http.anonymous(AbstractHttpConfigurer::disable);

 

 

 

사용처

로그인/회원가입은 로그인한 회원은 접근하지 못해야합니다.

        http.authorizeHttpRequests(request -> request
            .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .requestMatchers("/join", "/signup").anonymous()
            .anyRequest().permitAll()
        );

/join 과 /signup URL에는 로그인하지 않은 사용자만 접근하도록 설정했습니다.

 

 

결과

이제 로그인하고 /join 에 접속해보면

403 Forbidden 이 발생합니다.

 

 

Anonymous 인증의 동작 방식

 

Anonymous 인증 필터

  • AnonymousAuthenticationFilter가 인증되지 않은 요청을 처리
  • SecurityContextHolder에 인증 객체가 없을 때 AnonymousAuthenticationToken 생성
  • 필터 체인의 맨 마지막 부분에서 동작

 

인증 객체 구조

AnonymousAuthenticationToken {
    principal: "anonymousUser",
    authorities: ["ROLE_ANONYMOUS"],
    details: WebAuthenticationDetails,
    authenticated: true
}

 

 

 

주의사항

Anonymous 를 사용할 때에는 알고있어야 하는 것이 있습니다. AnonymousAuthenticationToken 이 SecurityContextHolder에 생성되었다는 것은 인증된 상태로 간주된다는 것입니다.

 

 

 

[Spring Security] 스프링 시큐리티 이해하기 (1)

Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Sprin

tmd8633.tistory.com

이 특징에 대해서 3-1 SecurityContextHolder 부분을 읽어보시면 이해가 되실겁니다.

 

 

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
System.out.println("isAuthenticated: " + auth.isAuthenticated());  // true 반환

이렇게 true가 반환되니 주의하시기 바랍니다.

 

실제 로그인한 사용자인지 파악하기위해서는

if (auth instanceof AnonymousAuthenticationToken) {
    // 익명 사용자
} else {
    // 실제 인증된 사용자
}

이렇게 사용하셔야합니다.

 

 

 

 

ExceptionHandling

http.exceptionHandling(handling -> handling
    .accessDeniedHandler()           // 권한 부족 시 처리
    .accessDeniedPage()             // 권한 부족 시 리다이렉트할 페이지
    .authenticationEntryPoint()     // 인증되지 않은 사용자 처리
    .defaultAuthenticationEntryPointFor()  // 특정 요청에 대한 인증 진입점 설정
);

 

 

accessDeniedHandler

.accessDeniedHandler((request, response, accessDeniedException) -> {
    // 인증된 사용자가 권한이 부족한 리소스에 접근할 때
    response.setContentType("text/html;charset=UTF-8");
    PrintWriter out = response.getWriter();
    out.println("<script>alert('권한이 없습니다.'); window.location.href='/';</script>");
    out.flush();
})

 

 

accessDeniedPage

.accessDeniedPage("/error/403")  // 간단히 특정 페이지로 리다이렉트

 

 

authenticationEntryPoint

.authenticationEntryPoint((request, response, authException) -> {
    // 인증되지 않은 사용자가 보호된 리소스에 접근할 때
    response.sendRedirect("/login");
})

 

 

defaultAuthenticationEntryPointFor

.defaultAuthenticationEntryPointFor(
    new LoginUrlAuthenticationEntryPoint("/api/login"),
    new AntPathRequestMatcher("/api/**")
)

 

 

전체 코드

        http.exceptionHandling(handling -> handling
            // 1. 권한 부족 처리 (403)
            .accessDeniedHandler((request, response, accessDeniedException) -> {
                response.setContentType("text/html;charset=UTF-8");
                PrintWriter out = response.getWriter();
                out.println("<script>alert('권한이 없습니다.'); history.back();</script>");
                out.flush();
            })
            
            // 2. 인증되지 않은 사용자 처리 (401)
            .authenticationEntryPoint((request, response, authException) -> {
                if (isAjaxRequest(request)) {
                    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
                } else {
                    response.sendRedirect("/login");
                }
            })
            
            // 3. API 요청에 대한 특별한 처리
            .defaultAuthenticationEntryPointFor(
                new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
                new AntPathRequestMatcher("/api/**")
            )
        );
        
    // AJAX 요청 확인
    private boolean isAjaxRequest(HttpServletRequest request) {
        return "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
    }

 

코드만 봐서는 헷갈리는게 많습니다. 정리해보자면 다음과 같습니다.

 

주요 차이점

  1. accessDeniedHandler vs authenticationEntryPoint
    • accessDeniedHandler: 인증은 됐지만 권한이 없을 때 (403)
    • authenticationEntryPoint: 인증이 안된 경우 (401)
  2. accessDeniedPage vs accessDeniedHandler
    • accessDeniedPage: 단순 페이지 리다이렉트
    • accessDeniedHandler: 더 복잡한 로직 구현 가능
  3. defaultAuthenticationEntryPointFor
    • 특정 URL 패턴에 대해서만 다른 인증 진입점 설정 가능
    • API와 웹 페이지를 다르게 처리할 때 유용

 

 

오늘은 여기까지하고 마치겠습니다.

Remember-Me란?

Remember-Me는 사용자가 브라우저를 닫았다가 다시 열어도 로그인 상태를 유지하는 기능입니다. 쿠키를 사용하여 구현되며, Spring Security는 두 가지 구현 방식을 제공합니다. 

 

  • Simple Hash-Based Token
  • Persistent Token (Database)

두가지 다 알아보도록 합시다.

 

 

 

RememberMe 처리과정

구현하기 전에 어떤 과정을 통해 쿠키로 만들어지는지 짚고 넘어가겠습니다.

  1. 로그인 시 rememberMe 체크박스를 선택하고 로그인
  2. 토큰생성 : base64(username + ":" + expirationTime + ":" + md5Hex(username + ":" + expirationTime + ":" + password + ":" + key))
  3. 토큰 저장
    • Simple Hash-Based Token : 쿠키 생성
    • Persistent Token : 쿠키생성, DB 저장ㅋ
  4. 사용자 접근 시 토큰 검증
    • Simple Hash-Based Token : 쿠키값이 유효한 경우 자동로그인
    • Persistent Token : DB 에 저장된 값과 비교하고 유효한 경우 자동로그인

 

 

Simple Hash-Based Token

    <form th:action="@{/join}" th:method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <label>
            <input type="checkbox" name="remember-me" /> Remember Me
        </label>
        <input type="submit" value="로그인">
    </form>

먼저 jonForm.html에 remember-me 체크박스를 하나 추가해줍시다.

 

그다음 RememberMeConfig 를 하나 만듭니다.

@Configuration
@RequiredArgsConstructor
public class RememberMeConfig {

    private static final String KEY = "MY_KEY";

    @Bean
    RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
        TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices(KEY, userDetailsService);
        rememberMe.setAlwaysRemember(false);
        rememberMe.setTokenValiditySeconds(60 * 60 * 24);
        return rememberMe;
    }
    
}

KEY는 비밀키 역할을 합니다. TokenBasedRememberMeServices에는 인코딩 알고리즘과 매칭 알고리즘이 존재합니다.

인코딩 알고리즘 기본값은 SHA-256, 매칭 알고리즘은 MD5입니다. 알고리즘은 설정을 통해 수정할 수 있습니다.

 

UserDetailsService는 저번 글에서 Bean으로 등록해놓았습니다.

 

    @Bean
    RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
        RememberMeTokenAlgorithm sha256 = RememberMeTokenAlgorithm.SHA256;
        TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices(KEY, userDetailsService, sha256);
        rememberMe.setMatchingAlgorithm(RememberMeTokenAlgorithm.MD5);
        rememberMe.setAlwaysRemember(false);
        rememberMe.setTokenValiditySeconds(60 * 60 * 24);
        return rememberMe;
    }

 

 

마지막으로 SecurityConfig로 돌아와

        http.rememberMe(remember -> remember
            .rememberMeServices(rememberMeServices)
        );

이렇게 설정해줍시다.

 

설정이 끝났습니다. 이제 로그인할때 rememberMe를 체크하고 로그인하면 쿠키가 살아있는동안 자동로그인이 되는것을 확인할 수 있습니다.

rememberMe 체크 후 로그인 -> 브라우저 종료 -> /user 로 바로 접속

 

 

 

 

Persistent Token

다음은 DB에 쿠키를 저장하는 방식으로 구현해보겠습니다.

 

build.gradle

	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	runtimeOnly 'com.h2database:h2'

 

application.properties

# DataSource 설정
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA 설정
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

 

@Entity
public class PersistentLogins {

    @Id
    private String series;
    private String username;
    private String token;
    private LocalDateTime lastUsed;
}

 

이렇게 추가해주도록 합시다.

PersistentLogins 객체는 공식문서에 적힌대로 작성했습니다. 실제로도 이렇게 해야합니다.

username: 사용자 이름
series: 쿠키의 series 값과 매칭됨
token: 쿠키의 token 값과 매칭됨
last_used: 마지막 사용 시간
 

Remember-Me Authentication :: Spring Security

Remember-me or persistent-login authentication refers to web sites being able to remember the identity of a principal between sessions. This is typically accomplished by sending a cookie to the browser, with the cookie being detected during future sessions

docs.spring.io

 

 

    @Bean
    RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
        PersistentTokenBasedRememberMeServices service = new PersistentTokenBasedRememberMeServices(KEY, userDetailsService, new CustomInMemoryTokenRepository());
        service.setAlwaysRemember(false);
        service.setTokenValiditySeconds(2592000);
        return service;
    }

 

RememberMeServices에 구현체를 TokenBasedRememberMeServices 에서 PersistentTokenBasedRememberMeServices로 변경해줍니다. PersistentTokenBasedRememberMeServices에는 PersistentTokenRepository가 들어가야하는데 그 구현체는

InMemoryTokenRepositoryImpl과 JdbcTokenRepositoryImpl이 있습니다.

 

저는 테스트하기 위해 InMemoryRepositoryImpl 을 구현할거고, 로그도 확인하기 위해 CustomInMemoryRepository 를 따로 구현했습니다. 그냥 InMemoryRepositoryImpl 넣으셔도 무방합니다.

 

public class CustomInMemoryTokenRepository extends InMemoryTokenRepositoryImpl {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public void createNewToken(PersistentRememberMeToken token) {
        super.createNewToken(token);
        logger.info("New token created: {}", token.getUsername());
    }

    @Override
    public void updateToken(String series, String tokenValue, Date lastUsed) {
        super.updateToken(series, tokenValue, lastUsed);
        logger.info("Token updated - series: {}, lastUsed: {}", series, lastUsed);
    }
}

 

 

이제 로그인해보면

정상적으로 토큰이 생성되었습니다. 이제 브라우저를 닫고 다시 접속해보면

 

잘 되는걸 볼 수 있습니다.

 

 

 

비교

따로 장단점을 적지않더라도 Persistent Token이 더 안전하다고 느끼실겁니다.

 

Persistent Token의 장점

  1. 토큰이 데이터베이스에 저장되므로 더 안전
  2. 토큰 탈취 시도를 감지 가능
  3. 사용자별 로그인 세션 관리 가능
  4. 특정 사용자의 remember-me 토큰만 선택적으로 만료 가능

Persistent Token 주의사항:

  1. 반드시 HTTPS 사용 권장
  2. 토큰 유효기간을 적절히 설정
  3. 중요한 작업 시에는 재인증 요구
  4. 정기적으로 만료된 토큰 정리 필요

Persistent Token을 사용할때

service.setUseSecureCookie(true);

로 설정하는게 좋습니다.

 

 

  • true로 설정 시
    • HTTPS 연결에서만 쿠키 전송
    • HTTP 연결에서는 쿠키가 전송되지 않음
    • 중간자 공격(Man-in-the-Middle) 방지
    • 쿠키 탈취 위험 감소
  • false로 설정 시
    • HTTP, HTTPS 모두에서 쿠키 전송
    • 개발 환경에서 주로 사용
    • 보안상 취약할 수 있음

개발환경에서는 false, 서비스환경에서는 true로 설정해줘야합니다.

 

+ Recent posts