등이 있다.
자주 쓰이는 것만 대강 추려보았다.
하나씩 알아보도록 하자.
아마 가장 보편적인 방식의 인증일 것이다.
기본적으로 유저의 ID와 비밀번호를 검증해 인증 여부를 판단한다.
이 방식은 하도 오래되었다 보니, ID/PW를 받는 법부터 검증을 하는 법까지 한 가지로 딱 되어 있지가 않다.
우선 ID/PW를 받는 법은 크게 3가지가 있다.
그 중에서 취소선을 그은 하나는 알아보지 않을 거다.
왜? 저건 보안이 상당히 떨어지기 때문에 '현대의 애플리케이션'에서는 사용이 권장되지 않는 방식이다. 만약 실제로 저 방식으로 구현된 애플리케이션이 있다고 해도, 유지보수를 하는 대신 그 방식을 제거하고 교체해야 하는 상황이다. 그러니 제외한다.
보안이 적용되는 URL에 접근을 하게 되면,
FilterSecurityInterceptor
의 발작버튼이 눌리게 된다. 그러면 우리가 지금까지 쭉 공부해 왔던 그 과정을 거쳐서 인증 여부를 따지고, 성공/실패가 응답으로 돌아간다. 이 때, 실패라면 AccessDeniedException
이 던져진다.
ExceptionTranslationFilter
에 의해 인증 절차를 시작한다. 즉, 구성된 AuthenticationEntryPoint를 이용해 로그인 페이지로 리디렉션을 보낸다.
브라우저가 리디렉션으로 보내진 로그인 페이지로 get 요청을 보낸다.
로그인 페이지가 응답으로 주어진다. 이전 글에 나왔지만, 아무 페이지도 구성하지 않았어도 기본 필터에 의해 기본적인 페이지가 렌더링되어 나온다.
ID/PW가 전송이 되면, UsernamePasswordAuthenticationFilter
에 의해 처리가 된다.
UsernamePasswordAuthenticationFilter
는 AbstractAuthenticationProcessingFilter
의 자식클래스이기 때문에 내부 인증 구조가 동일하다. 그러니 그 부분은 생략한다.
이것도 ID/PW 같은 자격 정보를 request header에다가 넣기 때문에 사실 보안이 조금 떨어지는 방법이긴 하다.
토큰을 사용해 인증을 처리하기 위해 아주 옛날에 고안된 방식인데, 단순하기 때문에 개발이 빠르고, 개인 정보를 다루지 않는 몇몇 특정 분야에서는 여전히 사용되고 있다.
Form Login의 1번과 같다.
Form Login의 2번과 같다.
여기가 조금 다른데, 구성된 AuthenticationEntryPoint
가 일단 BasicAuthenticationEntryPoint
의 구현체다. BasicAuthenticationEntryPoint
는 WWW-Authenticate header를 응답으로 보낸다. RequestCache
는 대개 NullRequestCache
이며 얘는 그 어떤 요청도 실제로 저장하지 않고 있는데, 그 이유는 클라이언트 측에서 자기가 보냈던 요청을 그대로 재생할 수 있기 때문이다.
클라이언트가 WWW-Authenticate header를 받으면, ID/PW를 가지고 인증을 다시 보낸다.
아래는 그 과정이다.
사용자가 ID/PW를 보내면, BasicAuthenticationFilter
가 UsernamePasswordAuthenticationToken
을 생성한다.
UsernamePasswordAuthenticationToken
이 AuthenticationManager
에 넘겨진다.
인증 실패 시,
SecurityContextHolder
가 청소된다.RememberMeServices.loginFail
가 호출된다. RememberMeServices
미 이용 시 이 단계는 건너뛴다.AuthenticationEntryPoint
가 호출되어 WWW-Authenticate를 재전송하게 한다.인증 성공 시,
SecurityContextHolder
에 Authentication
이 담긴다.RememberMeServices.loginSucces
가 호출된다. RememberMeServices
미 이용 시 이 단계는 건너뛴다.BasicAuthenticationFilter
가 FilterChain.doFilter(request,response)
를 호출해 애플리케이션 로직의 남은 단계들을 계속해서 수행한다.인 메모리를 활용한 인증이다. 일반적으로 많이 쓰일 것 같지는 않고, 테스트용으로 쓸 수는 있을 것 같다.
UserDetailsService
을 구현한 InMemoryUserDetailsManager
라는 친구가 있다.
이 친구도 ID/PW 기반의 인증을 지원하는데, 이 데이터를 메모리에서 가져온다.
InMemoryUserDetailsManager
는 UserDetailsService
의 구현체이기에 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를 이용한 인증 방식이다.
JdbcDaoImpl
도 UserDetailsService
의 구현체 중 하나인데, 이 친구는 JDBC를 이용해 ID/PW를 받는다. 그러니까 DB에서 받는다는 소리다.
JdbcUserDetailsManager
는 JdbcDaoImpl
의 자식클래스이고, 그렇기 때문에 역시 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
는 ID / PW / 그 외 인증에 필요한 다른 속성들을 얻기 위해DaoAuthenticationProvider
에 의해 사용된다.
Spring Security는 UserDetailsService
의 구현체로 인메모리 방식와 JDBC 방식을 제공한다.
@Bean
CustomUserDetailsService customUserDetailsService() {
return new CustomUserDetailsService();
}
위와 같이, 커스팀 UserDetailsService
을 빈으로 등록함으로써 커스텀 인증을 정의할 수도 있다.
DaoAuthenticationProvider
는 AuthenticationProvider
의 구현체 중 하나로 UserDetailsService
와 PasswordEncoder
를 활용하여 ID/PW를 인증한다.
아래 그림은 DaoAuthenticationProvider
의 동작 방식이다.
사용자의 ID/PW를 읽은 필터는 생성한 UsernamePasswordAuthenticationToken
을 ProviderManager
의 구현체인 AuthenticationManage
에 넘긴다.
ProviderManager
는 DaoAuthenticationProvider
타입의 AuthenticationProvider
를 이용한다고 설정된다.
DaoAuthenticationProvider
가 UserDetailsService
로부터 UserDetails
를 찾는다.
그리고는 PasswordEncoder
를 이용해 3번에서 찾은 UserDetails
의 비밀번호를 확인한다.
인증에 성공하면 UserDetails
과 Authorities
가 담겨있는 UsernamePasswordAuthenticationToken
이 리턴되고, 이내 인증 필터에 의해 SecurityContextHolder
에 담긴다.
LDAP는 종종 사용자 정보의 중앙 저장소 및 인증 서비스로 사용된다.
당연하지만 사용자의 역할 정보 또한 저장이 가능하다.
LDAP 기반 인증 또한 자격 증명으로 ID와 PW를 받을 때 이용이 가능하다.
하지만, 다른 인증 방식들과 다르게 UserDetailsService
에 통합되지 못한다.
그 이유는 바인드 인증에서 LDAP 서버가 암호를 반환하지 않아 애플리케이션이 암호 유효성 검사를 수행할 수 없기 때문이다.
그렇기 때문에 Spring Security의 LDAP 공급자가 온전하게 구성되기 위해서는 인증 및 권한 정보의 추출을 위해 별도의 전략 인터페이스를 사용하고 다양한 상황에 대응 가능한 기본적인 구현체를 제공할 필요가 있다.
이 글은 각 인증법에 대한 대략적인 구조와 절차에 대해 다루고 있기 때문에 자세한 구현법은 다른 글에서 소개한다.
Remember-me
혹은 persistent-login
인증은 세션 간 principal의 identity를 유지하는 방식이다.
이를 위해서는 브라우저의 쿠키에 관련 정보를 저장할 필요가 있다.
Spring Security는 2가지 타입의 구현을 지원하는데, 하나는 해싱을 이용해 쿠키 기반 토큰의 보안을 유지하고, 다른 하나는 데이터베이스 같은 영구 저장 메커니즘을 사용하여 생성된 토큰을 저장한다.
2가지 구현 모두 UserDetailsService
의 구현체를 요구한다.
그러니까 UserDetailsService
를 사용하지 않는 LDAP 인증 방식으로는 이걸 직접적으로 할 수는 없다는 소리다. 정 필요하다면 따로 UserDetailsService
의 구현체를 Bean으로 등록해야 한다.
저장되는 쿠키의 형식은 다음과 같다.
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 방식을 사용하지 말아야 한다.
이 방식은 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는 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
이 없을 때 호출된다.
Hash-Based Token 방식의 서비스에 대해서는 굳이 설명하지 않는다.
왜? 보안이 구리니까.
PersistentTokenBasedRememberMeServices
은 RememberMeAuthenticationToken
를 생성하고, 이렇게 생성된 토큰은 RememberMeAuthenticationProvider
에 의해 처리가 된다.
말했다시피 PersistentTokenBasedRememberMeServices
는 ID/PW/Authorities 등의 정보 추출을 위해 UserDetailsService
를 필요로 한다.
이렇게 만들어진 서비스는 UsernamePasswordAuthenticationFilter.setRememberMeServices()
를 통해 등록될 필요가 있다.
또한 RememberMeAuthenticationProvider
는 AuthenticationManager.setProviders()
를 통해 Provider로서 등록되어야 한다.
FilterChainProxy
에 RememberMeAuthenticationFilter
또한 추가해줘야 하는데 보통 UsernamePasswordAuthenticationFilter
뒤에 등록한다.
마지막으로 토큰을 DB에 저장하기 위해 PersistentTokenRepository
가 필요하다.
두 가지 타입의 구현이 지원된다.
InMemoryTokenRepositoryImpl
: 테스트 목적이다.JdbcTokenRepositoryImpl
: 토큰을 DB에 저장한다.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과 아주 유사하다.
소셜 로그인용으로도 쓰이지만, Form Login 방식을 대체하지 못하는 것도 아니다.
Basic Token을 사용하는 인증 방식과 유사한 형태를 보이는데, 보안 수준이 이쪽이 더 좋아서 대강 상위호환이라고 봐도 무방하다.
참고로 JWT는 Bearer Token의 일종이다.
비슷한 그림을 여러번 보니, 이제 그림을 보자마자 뭐가 어떻게 돌아가는 건지 알 것 같지 않은가?
사용자가 bearer token를 전송하면 BearerTokenAuthenticationFilter
가 BearerTokenAuthenticationToken
을 생성한다. 이 토큰은 HttpServletRequest
로부터 토큰을 추출하는 것으로 인해 만들어진 Authentication
객체의 한 종류이다.
HttpServletRequest
가 AuthenticationManagerResolver
로 전달된다. 그리고 Resolver에 의해 AuthenticationManager
가 선택된다. 위에서 만들어진 BearerTokenAuthenticationToken
은 AuthenticationManager
전달되어 인증 절차가 수행된다.
인증에 실패하면,
SecurityContextHolder
가 비워진다.AuthenticationEntryPoint
가 호출되어 WWW-Authenticate header 재전송을 요구한다.인증에 성공하면,
SecurityContextHolder
에 Authentication
이 담긴다.BearerTokenAuthenticationFilter
가 FilterChain.doFilter(request,response)
를 호출해 애플리케이션 로직의 남은 부분을 계속 진행한다.