[Spring] Spring Security - 3

얄루얄루·2022년 12월 12일
0

Spring

목록 보기
7/14

Authentication의 주요 매커니즘

  • Username and Password
  • OAuth 2.0 Login
  • Remember Me

등이 있다.

자주 쓰이는 것만 대강 추려보았다.

하나씩 알아보도록 하자.

Username/Password Authentication

아마 가장 보편적인 방식의 인증일 것이다.

기본적으로 유저의 ID와 비밀번호를 검증해 인증 여부를 판단한다.

이 방식은 하도 오래되었다 보니, ID/PW를 받는 법부터 검증을 하는 법까지 한 가지로 딱 되어 있지가 않다.

우선 ID/PW를 받는 법은 크게 3가지가 있다.

  • Form 로그인 방식
  • Basic 인증 방식
  • Digest 인증 방식

그 중에서 취소선을 그은 하나는 알아보지 않을 거다.

왜? 저건 보안이 상당히 떨어지기 때문에 '현대의 애플리케이션'에서는 사용이 권장되지 않는 방식이다. 만약 실제로 저 방식으로 구현된 애플리케이션이 있다고 해도, 유지보수를 하는 대신 그 방식을 제거하고 교체해야 하는 상황이다. 그러니 제외한다.

Form Login

  1. 보안이 적용되는 URL에 접근을 하게 되면,

  2. FilterSecurityInterceptor의 발작버튼이 눌리게 된다. 그러면 우리가 지금까지 쭉 공부해 왔던 그 과정을 거쳐서 인증 여부를 따지고, 성공/실패가 응답으로 돌아간다. 이 때, 실패라면 AccessDeniedException이 던져진다.

  3. ExceptionTranslationFilter에 의해 인증 절차를 시작한다. 즉, 구성된 AuthenticationEntryPoint를 이용해 로그인 페이지로 리디렉션을 보낸다.

  4. 브라우저가 리디렉션으로 보내진 로그인 페이지로 get 요청을 보낸다.

  5. 로그인 페이지가 응답으로 주어진다. 이전 글에 나왔지만, 아무 페이지도 구성하지 않았어도 기본 필터에 의해 기본적인 페이지가 렌더링되어 나온다.

ID/PW가 전송이 되면, UsernamePasswordAuthenticationFilter에 의해 처리가 된다.

UsernamePasswordAuthenticationFilterAbstractAuthenticationProcessingFilter의 자식클래스이기 때문에 내부 인증 구조가 동일하다. 그러니 그 부분은 생략한다.

Basic Authentication

이것도 ID/PW 같은 자격 정보를 request header에다가 넣기 때문에 사실 보안이 조금 떨어지는 방법이긴 하다.

토큰을 사용해 인증을 처리하기 위해 아주 옛날에 고안된 방식인데, 단순하기 때문에 개발이 빠르고, 개인 정보를 다루지 않는 몇몇 특정 분야에서는 여전히 사용되고 있다.

  1. Form Login의 1번과 같다.

  2. Form Login의 2번과 같다.

  3. 여기가 조금 다른데, 구성된 AuthenticationEntryPoint가 일단 BasicAuthenticationEntryPoint의 구현체다. BasicAuthenticationEntryPoint는 WWW-Authenticate header를 응답으로 보낸다. RequestCache는 대개 NullRequestCache이며 얘는 그 어떤 요청도 실제로 저장하지 않고 있는데, 그 이유는 클라이언트 측에서 자기가 보냈던 요청을 그대로 재생할 수 있기 때문이다.

클라이언트가 WWW-Authenticate header를 받으면, ID/PW를 가지고 인증을 다시 보낸다.

아래는 그 과정이다.

  1. 사용자가 ID/PW를 보내면, BasicAuthenticationFilterUsernamePasswordAuthenticationToken을 생성한다.

  2. UsernamePasswordAuthenticationTokenAuthenticationManager에 넘겨진다.

  3. 인증 실패 시,

    • SecurityContextHolder가 청소된다.
    • RememberMeServices.loginFail가 호출된다. RememberMeServices 미 이용 시 이 단계는 건너뛴다.
    • AuthenticationEntryPoint가 호출되어 WWW-Authenticate를 재전송하게 한다.
  4. 인증 성공 시,

    • SecurityContextHolderAuthentication이 담긴다.
    • RememberMeServices.loginSucces가 호출된다. RememberMeServices 미 이용 시 이 단계는 건너뛴다.
    • BasicAuthenticationFilterFilterChain.doFilter(request,response)를 호출해 애플리케이션 로직의 남은 단계들을 계속해서 수행한다.

In-Memory Authentication

인 메모리를 활용한 인증이다. 일반적으로 많이 쓰일 것 같지는 않고, 테스트용으로 쓸 수는 있을 것 같다.

UserDetailsService을 구현한 InMemoryUserDetailsManager라는 친구가 있다.

이 친구도 ID/PW 기반의 인증을 지원하는데, 이 데이터를 메모리에서 가져온다.

InMemoryUserDetailsManagerUserDetailsService의 구현체이기에 UserDetails을 관리할 수가 있다.

UserDetails 기반의 인증은 Spring Security에서 인증용 자격 증명으로 ID/PW를 받을 때 사용된다.

아래와 같은 식으로 미리 ID와 PW, Authorities를 메모리에 올려놓고 사용할 수 있다.

@Bean
public UserDetailsService users() {
    // The builder will ensure the passwords are encoded before saving in memory
    UserBuilder users = User.withDefaultPasswordEncoder();
    UserDetails user = users
        .username("user")
        .password("password")
        .roles("USER")
        .build();
    UserDetails admin = users
        .username("admin")
        .password("password")
        .roles("USER", "ADMIN")
        .build();
    return new InMemoryUserDetailsManager(user, admin);
}

JDBC Authentication

JDBC를 이용한 인증 방식이다.

JdbcDaoImplUserDetailsService의 구현체 중 하나인데, 이 친구는 JDBC를 이용해 ID/PW를 받는다. 그러니까 DB에서 받는다는 소리다.

JdbcUserDetailsManagerJdbcDaoImpl의 자식클래스이고, 그렇기 때문에 역시 UserDetailsManager 인터페이스를 통해 UserDetails의 관리가 가능하다.

이 친구가 인증을 수행하려면 역시 DB와 Table이 있어야 하는데,

Spring Security에 의해 기본적인 스키마가 제공된다.

create table users(
    username varchar_ignorecase(50) not null primary key,
    password varchar_ignorecase(500) not null,
    enabled boolean not null
);

create table authorities (
    username varchar_ignorecase(50) not null,
    authority varchar_ignorecase(50) not null,
    constraint fk_authorities_users foreign key(username) references users(username)
);
create unique index ix_auth_username on authorities (username,authority);

org/springframework/security/core/userdetails/jdbc/users.ddl <- 이 경로에서도 확인이 가능하다.

DB가 있으니 이제 JdbcUserDetailsManager를 구성해야 하는데, 그 전에 DataSource를 생성해야 한다.

아래의 코드로 기본 스키마를 이용한 내장 DataSource를 생성할 수 있다.

@Bean
DataSource dataSource() {
    return new EmbeddedDatabaseBuilder()
        .setType(H2)
        .addScript("classpath:org/springframework/security/core/userdetails/jdbc/users.ddl")
        .build();
}

마지막으로 실데이터를 삽입한다.

비밀번호를 넣는 방식이 위와는 다른데, 각 인증 매니저마다 정해져 있는 방식이 있는 것은 아니다.

인 메모리도 이런 식으로 넣어도 된다.

반대로, 여기서 DefaultPasswordEncoder를 사용해도 된다.

@Bean
UserDetailsManager users(DataSource dataSource) {
    UserDetails user = User.builder()
        .username("user")
        .password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
        .roles("USER")
        .build();
    UserDetails admin = User.builder()
        .username("admin")
        .password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
        .roles("USER", "ADMIN")
        .build();
    JdbcUserDetailsManager users = new JdbcUserDetailsManager(dataSource);
    users.createUser(user);
    users.createUser(admin);
}

UserDetailsService

UserDetailsService는 ID / PW / 그 외 인증에 필요한 다른 속성들을 얻기 위해DaoAuthenticationProvider에 의해 사용된다.

Spring Security는 UserDetailsService의 구현체로 인메모리 방식와 JDBC 방식을 제공한다.

@Bean
CustomUserDetailsService customUserDetailsService() {
    return new CustomUserDetailsService();
}

위와 같이, 커스팀 UserDetailsService을 빈으로 등록함으로써 커스텀 인증을 정의할 수도 있다.

DaoAuthenticationProvider

DaoAuthenticationProviderAuthenticationProvider의 구현체 중 하나로 UserDetailsServicePasswordEncoder를 활용하여 ID/PW를 인증한다.

아래 그림은 DaoAuthenticationProvider의 동작 방식이다.

  1. 사용자의 ID/PW를 읽은 필터는 생성한 UsernamePasswordAuthenticationTokenProviderManager의 구현체인 AuthenticationManage에 넘긴다.

  2. ProviderManagerDaoAuthenticationProvider 타입의 AuthenticationProvider를 이용한다고 설정된다.

  3. DaoAuthenticationProviderUserDetailsService로부터 UserDetails를 찾는다.

  4. 그리고는 PasswordEncoder를 이용해 3번에서 찾은 UserDetails의 비밀번호를 확인한다.

  5. 인증에 성공하면 UserDetailsAuthorities가 담겨있는 UsernamePasswordAuthenticationToken이 리턴되고, 이내 인증 필터에 의해 SecurityContextHolder에 담긴다.

LDAP Authentication

LDAP는 종종 사용자 정보의 중앙 저장소 및 인증 서비스로 사용된다.

당연하지만 사용자의 역할 정보 또한 저장이 가능하다.

LDAP 기반 인증 또한 자격 증명으로 ID와 PW를 받을 때 이용이 가능하다.

하지만, 다른 인증 방식들과 다르게 UserDetailsService에 통합되지 못한다.

그 이유는 바인드 인증에서 LDAP 서버가 암호를 반환하지 않아 애플리케이션이 암호 유효성 검사를 수행할 수 없기 때문이다.

그렇기 때문에 Spring Security의 LDAP 공급자가 온전하게 구성되기 위해서는 인증 및 권한 정보의 추출을 위해 별도의 전략 인터페이스를 사용하고 다양한 상황에 대응 가능한 기본적인 구현체를 제공할 필요가 있다.

이 글은 각 인증법에 대한 대략적인 구조와 절차에 대해 다루고 있기 때문에 자세한 구현법은 다른 글에서 소개한다.

Remember-Me Authentication

Remember-me 혹은 persistent-login 인증은 세션 간 principal의 identity를 유지하는 방식이다.

이를 위해서는 브라우저의 쿠키에 관련 정보를 저장할 필요가 있다.

Spring Security는 2가지 타입의 구현을 지원하는데, 하나는 해싱을 이용해 쿠키 기반 토큰의 보안을 유지하고, 다른 하나는 데이터베이스 같은 영구 저장 메커니즘을 사용하여 생성된 토큰을 저장한다.

2가지 구현 모두 UserDetailsService의 구현체를 요구한다.

그러니까 UserDetailsService를 사용하지 않는 LDAP 인증 방식으로는 이걸 직접적으로 할 수는 없다는 소리다. 정 필요하다면 따로 UserDetailsService의 구현체를 Bean으로 등록해야 한다.

Hash-Based Token 방식

저장되는 쿠키의 형식은 다음과 같다.

base64(username + ":" + expirationTime + ":" +
md5Hex(username + ":" + expirationTime + ":" password + ":" + key))

username:          UserDetailsService에서 식별 가능
password:          UserDetails에서 추출된 것과 일치함
expirationTime:    Remember-Me 토큰이 만료되는 날짜 및 시간(밀리초 단위)
key:               토큰의 수정을 방지하기 위한 개인 키

key를 정하기 위해서는 다음과 같이 명시해줘야 한다.

<http>
...
<remember-me key="myAppKey"/>
</http>

보다시피 이 방식은 username, password, key가 변경되지 않는다는 전제하에서 지정된 기간 동안만 유효하다.

다르게 말하면 그 기간 동안 변경이 없으면 만능키라는 것과 같다. 그렇기 때문에 보안 이슈가 있다.

이전의 글에서도 말했지만, 보안을 향상시키기 위해서는 후술할 Persistent Token 방식을 이용하거나 아예 Remember-Me 방식을 사용하지 말아야 한다.

Persistent Token 방식

이 방식은 DB에 관련 정보를 저장하기 때문에 다음과 같이 DataSource를 명시해줘야 한다.

<http>
...
<remember-me data-source-ref="someDataSource"/>
</http>

그리고 해당 DB는 다음과 같은 항목들이 필요하다.

create table persistent_logins (username varchar(64) not null,
                                series varchar(64) primary key,
                                token varchar(64) not null,
                                last_used timestamp not null)

Remember-Me 인터페이스와 구현체

Remember-Me는 UsernamePasswordAuthenticationFilter와 함께 사용되고, 해당 클래스의 부모클래스인 AbstractAuthenticationProcessingFilter에서 후킹을 함으로써 구현된다.

인터페이스의 구조는 다음과 같다.

// RememberMeAuthenticationFilter에 의해 호출됨.
Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);

// AbstractAuthenticationProcessingFilter에 의해 호출됨.
void loginFail(HttpServletRequest request, HttpServletResponse response);

// AbstractAuthenticationProcessingFilter에 의해 호출됨.
void loginSuccess(HttpServletRequest request, HttpServletResponse response,
    Authentication successfulAuthentication);

주석으로도 써 있지만 autoLogin만이 RememberMeAuthenticationFilter에 의해 호출된다.

그럼 언제 호출되냐?

SecurityContextHolder 안에 적절한 Authentication이 없을 때 호출된다.

PersistentTokenBasedRememberMeServices

Hash-Based Token 방식의 서비스에 대해서는 굳이 설명하지 않는다.
왜? 보안이 구리니까.

PersistentTokenBasedRememberMeServicesRememberMeAuthenticationToken를 생성하고, 이렇게 생성된 토큰은 RememberMeAuthenticationProvider에 의해 처리가 된다.

말했다시피 PersistentTokenBasedRememberMeServices는 ID/PW/Authorities 등의 정보 추출을 위해 UserDetailsService를 필요로 한다.

이렇게 만들어진 서비스는 UsernamePasswordAuthenticationFilter.setRememberMeServices()를 통해 등록될 필요가 있다.

또한 RememberMeAuthenticationProviderAuthenticationManager.setProviders()를 통해 Provider로서 등록되어야 한다.

FilterChainProxyRememberMeAuthenticationFilter 또한 추가해줘야 하는데 보통 UsernamePasswordAuthenticationFilter 뒤에 등록한다.

마지막으로 토큰을 DB에 저장하기 위해 PersistentTokenRepository가 필요하다.

두 가지 타입의 구현이 지원된다.

  • InMemoryTokenRepositoryImpl : 테스트 목적이다.
  • JdbcTokenRepositoryImpl : 토큰을 DB에 저장한다.

OAuth 2.0 Login

OAuth 2.0 로그인 기능은 사용자가 OAuth 2.0 공급자(e.g. GitHub) 또는 OpenID Connect 1.0 공급자(e.g. Google)의 계정을 사용하여 애플리케이션에 로그인하도록 하는 기능을 애플리케이션에 제공한다.

단순히 소셜 로그인이라 부른다.

OAuth 2.0 로그인은 OAuth 2.0 Authorization Framework 및 OpenID Connect Core 1.0에 지정된 대로 Authorization Code Grant를 사용하여 구현된다.

직접 Provider를 구성하려면 상당히 복잡한데, CommonOAuth2Provider를 이용해 유명한 몇몇 공급자(e.g. GitHub, Google, Facebook)를 이용한 소셜 로그인을 구현할 수 있다.

그리고 인증 과정에서 OAuth Client를 사용할 수 있게끔 구성해줘야 한다.

application.yml에 다음과 같은 항목을 삽입함으로써 할 수 있다.

spring:
  security:
    oauth2:
      client:
        registration:
          google-login: 
            provider: google    
            client-id: google-client-id
            client-secret: google-client-secret

다만 OAuth 2.0 공급자 중에 멀티 테넌시를 이용하는 경우도 있을 수 있는데, Okta가 대표적이다. 이 경우, 그 친구들이 정해 둔 OAuth Client를 위한 엔드포인트를 맞춰줘야 한다.

spring:
  security:
    oauth2:
      client:
        registration:
          google-login: 
            provider: google    
            client-id: google-client-id
            client-secret: google-client-secret
          okta:
            client-id: okta-client-id
            client-secret: okta-client-secret
        provider:
          okta: 
            authorization-uri: https://your-subdomain.oktapreview.com/oauth2/v1/authorize
            token-uri: https://your-subdomain.oktapreview.com/oauth2/v1/token
            user-info-uri: https://your-subdomain.oktapreview.com/oauth2/v1/userinfo
            user-name-attribute: sub
            jwk-set-uri: https://your-subdomain.oktapreview.com/oauth2/v1/keys

결국 최종적으로 JWT를 받아 인증을 하게 되는데, 이 과정이 Basic Authentication과 아주 유사하다.

JWT

소셜 로그인용으로도 쓰이지만, Form Login 방식을 대체하지 못하는 것도 아니다.

Basic Token을 사용하는 인증 방식과 유사한 형태를 보이는데, 보안 수준이 이쪽이 더 좋아서 대강 상위호환이라고 봐도 무방하다.

참고로 JWT는 Bearer Token의 일종이다.

비슷한 그림을 여러번 보니, 이제 그림을 보자마자 뭐가 어떻게 돌아가는 건지 알 것 같지 않은가?

  1. 사용자가 bearer token를 전송하면 BearerTokenAuthenticationFilterBearerTokenAuthenticationToken을 생성한다. 이 토큰은 HttpServletRequest로부터 토큰을 추출하는 것으로 인해 만들어진 Authentication 객체의 한 종류이다.

  2. HttpServletRequestAuthenticationManagerResolver로 전달된다. 그리고 Resolver에 의해 AuthenticationManager가 선택된다. 위에서 만들어진 BearerTokenAuthenticationTokenAuthenticationManager 전달되어 인증 절차가 수행된다.

  3. 인증에 실패하면,

    • SecurityContextHolder가 비워진다.
    • AuthenticationEntryPoint가 호출되어 WWW-Authenticate header 재전송을 요구한다.
  4. 인증에 성공하면,

    • SecurityContextHolderAuthentication이 담긴다.
    • BearerTokenAuthenticationFilterFilterChain.doFilter(request,response)를 호출해 애플리케이션 로직의 남은 부분을 계속 진행한다.

References

profile
시간아 늘어라 하루 48시간으로!

0개의 댓글