23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
Spring Security는 웹 애플리케이션 보안의 사실상 표준으로 자리 잡은 강력한 프레임워크로, 세션 관리, 접근 제어, 다양한 인증 방식 지원 등 폭넓고 유연한 기능을 제공합니다.
그러나 실제로 1개 이상의 인증 수단을 동일한 인가 로직과 세션 정책 하에 관리하려면, SecurityFilterChain을 구성하는 여러 필터의 연계와 우선순위 조정이 쉽지 않습니다.
또한 Spring Security의 기본 제공 구현(JDBC 기반 세션 관리, Spring Authorization Server 등)은 범용성을 위해 추상화되어 있어,
세부 정책을 비즈니스 로직과 긴밀하게 결합하기에는 제약이 따르는 경우가 많습니다.
예를 들어, UserDetails 기반의 Username/Password 인증을 활성화한 상태에서 Spring Authorization Server 기반 OAuth 회원과 통합하려면
필터 체인의 우선순위와 인증 흐름을 재정렬해야 하며, 실무적으로는 양쪽 구성 모두를 일정 부분 커스터마이징하는 일이 불가피합니다.
한편 실제 개발 환경은 짧은 주기 속에서 빠른 구축·배포가 요구되고, 인증이 필요한 서비스도 예외가 아닙니다.
각 시스템은 비즈니스 특성에 따라 Form Login, Basic Auth, OAuth 클라이언트 인증 등 서로 다른 방식을 채택할 수 있지만,
그럼에도 모든 서비스에는 동일한 수준의 접근 제어 정책과 세션 관리 규칙이 일관되게 적용되어야 합니다.
이를 매번 직접 구현하면 프로젝트마다 복잡한 보안 설정이 반복되고, 서비스가 늘어날수록 관리·유지보수 부담이 기하급수적으로 커집니다.
결국 Spring Security를 사용하더라도 통합적이고 일관된 인증·인가 체계를 갖추는 일은 결코 간단하지 않습니다.
Fortel은 이러한 배경에서 개발된 사내 전용 Spring Security 확장 라이브러리입니다.
Fortel은 세션을 먼저 검증하는 SessionResolver를 통해 세션 정책(세션 고정 방지, 동시 로그인 제한, 미인증 접근 처리)을 선적용하고,
이후 AuthenticationConverter → AuthenticationManager → Authorization 순서로 인증과 인가를 단일 관문에서 오케스트레이션합니다.
또한 AuthenticationConverter를 활용해 Form/Basic/OAuth 등 다양한 인증 입력을 동일한 파이프라인으로 수용하고,
Authentication의 principal로 UserDetails 기반 도메인 모델을 일관되게 사용함으로써,
필터 간 연계·우선순위 조정의 복잡성을 줄이면서 여러 인증 체계를 통합적으로 관리할 수 있게 설계했습니다.
정리하면, Fortel은 인증·인가 로직, 세션 정책, 리소스 접근 제어를 YAML 기반 설정으로 선언하고 이를 표준화된 구조로 자동 구성해 Spring Security 환경에 적용합니다.
이를 통해 서비스마다 다른 인증 방식을 사용하더라도 일관된 보안 정책을 유지할 수 있고, 개발자는 복잡한 설정 작업에서 벗어나 핵심 비즈니스 로직과 사용자 경험에 집중할 수 있습니다.
YAML 기반 인증·인가 구성: Spring Security DSL 없이 정책을 선언적으로 설정
여러 인증 방식 지원: Form / JSON Login, Basic Auth, OAuth 클라이언트 인증 통합 관리
세션 및 리소스 제어 내장: 세션 고정 방어, 동시 로그인 제어, 다양한 접근 정책 관리
AccessGateFilter 중심 구조: 세션 선검증 → 인증 → 인가를 단일 필터에서 통합 처리
Spring Security와의 자연스러운 통합: FilterChain 재정의 없이 확장
Fortel은 SecurityFilterChain을 직접 작성하지 않습니다. 대신 아래 YAML 설정을 기반으로 인증·인가·세션 정책을 구성하고, 이를 AccessGateFilter에 등록해 동작합니다.
fortel:
secret-key: "XXX"
session:
fixation-enabled: true
concurrent-login-count: 1
login-required-policy: redirect_to_login
login-page-url: "/login.html"
resources:
use-default: true
login-processing:
enabled: true
login-page-url: "/login.html"
anonymous-allows:
- pattern: "/join.html"
- pattern: "/join"
http-method: POST
basic-auth:
enabled: true
clients:
- client-id: "basic-auth-client-XXX"
client-secret: "XXX"
request-matchers:
- pattern: "/basic-auth/**"
o-auth-client:
enabled: true
clients:
- uid: google
connect-type: connect
base-url: "https://accounts.google.com"
client-id: "XXX"
client-secret: "XXX"
authorize-url: "/o/oauth2/auth"
token-url: "/o/oauth2/token"
callback-base-url: "http://localhost:8080"
login-required-policy: redirect_to_login
login-page-url: "/login.html"
- uid: github
connect-type: new
base-url: "https://github.com"
client-id: "XXX"
client-secret: "XXX"
authorize-url: "/login/oauth/authorize"
token-url: "/login/oauth/access_token"
callback-base-url: "http://localhost:8080"
redundant-login-policy: redirect
login-success-url: "/"
request-matchers:
- pattern: "/o-auth/**"Fortel은 Spring Security의 개별 표준 필터에 직접 의존하지 않고, 세션 선검증 → 인증 → 인가를 단일 진입점인 AccessGateFilter에서 통합 오케스트레이션하도록 설계되었습니다.
AccessGateFilter는 Spring Security가 제공하는 Authentication/UserDetails 모델, AuthenticationConverter,
AuthenticationManager 등과 호환되는 인터페이스를 기반으로 구현되어, Fortel의 정책(YAML)과 결합해 요청 단위 인가까지 일괄 처리합니다.
또한 SecurityFilterChain을 재정의하지 않고, YAML 기반 정책을 주입받아 한 관문에서 조율하는 방식으로 동작하므로,
기존 Spring Security 기반 시스템에도 최소 변경으로 도입할 수 있고 서비스 전반에 일관된 세션 관리·인증·인가 정책을 적용할 수 있습니다.
아래 예시는 Fortel의 핵심 흐름을 작게 발췌·단순화한 코드입니다(실제 구현과 일부 차이가 있을 수 있습니다).
세션 고정 방지, 동시 로그인 제한, IP/User-Agent 점검 등 정책을 인증 전에 강제합니다.
public class LoginSessionResolver {
public LoginToken resolve(HttpServletRequest req) throws AuthenticationException {
return parse(req)
.flatMap(loginSession -> resolvers.stream()
.filter(r -> r.matches(loginSession))
.findFirst()
.map(resolver -> resolver.resolve(loginSession))
)
.orElse(null);
}
private Optional<LoginSession> parse(HttpServletRequest req) {
return findCookie(req, fortel.getSession().getSessionKeyName())
.map(cookie -> secretEncoder.decrypt(cookie.getValue(), LoginSession.class))
.map(session -> {
if (fortel.getSession().isCheckIp() &&
!StringUtils.equals(session.getIp(), WebUtils.getIp(req))) {
throw new CookieTheftException();
}
if (fortel.getSession().isCheckUserAgent() &&
!StringUtils.equals(session.getUserAgent(), WebUtils.getUserAgent(req))) {
throw new CookieTheftException();
}
return session;
});
}
}핵심: “세션 정책 불일치”는 인증 시도 이전에 차단되어, 이후 파이프라인을 깔끔하게 유지합니다.
Form과 OAuth 같은 서로 다른 입력을 동일 파이프라인으로 수용합니다.
// Form Login → Username/Password 토큰으로 변환
public class FormLoginProcessingConverter implements AnonymousRequiredConverter {
@Override
public Authentication convertInternal(HttpServletRequest req) throws AuthenticationException {
try {
String identifier = MAPPER.apply(req, fortel.getLoginProcessing().getIdentifierName());
String password = MAPPER.apply(req, fortel.getLoginProcessing().getPasswordName());
if (StringUtils.isEmpty(identifier) || StringUtils.isEmpty(password)) {
throw new InvalidLoginRequestException();
}
return unauthenticated(identifier, password);
} catch (Exception e) {
throw new InvalidLoginRequestException(e);
}
}
}
// OAuth Login → Authorication Code 요청 토큰으로 변환
public class OAuthClientConnectLoginHandler implements LoginRequiredConverter {
@Override
public Authentication convertInternal(LoginToken loginToken,
HttpServletRequest req) throws AuthenticationException {
return findClient(req)
.map(details -> OAuthClientToken.unauthenticated(details, loginToken.getBaseDetails()))
.orElseThrow(OAuthClientNotFoundedException::new);
}
}핵심: 컨버터는 “해당되지 않으면 통과(null)” 원칙으로 체이닝되고, 적용 대상만 토큰화합니다.
토큰의 타입별로 인증을 수행하고, 성공 시 authenticated(...)를 통해 인증 완료 토큰을 반환합니다.
public class BasicAuthHandler extends AnnotationMatcher<BasicAuth> implements TypedAuthenticationManager<BasicAuthToken> {
@Override
public BasicAuthToken doAuthenticate(BasicAuthToken authentication) throws AuthenticationException {
BasicAuthDetails basicAuth = findDetails(authentication);
if (Objects.isNull(basicAuth)) {
return authentication;
}
return authenticated(basicAuthDetailsService.authenticate(basicAuth));
}
}핵심: 타입에 맞는 매니저가 선택되어 인증을 수행합니다.
URL/메서드/권한을 기준으로 컨트롤러 진입 전 접근을 결정합니다.
public class LoginProcessingHandler implements TypedAuthorizeRequest<LoginProcessingToken> {
@Override
public boolean doAuthorize(HttpServletRequest req,
LoginProcessingToken authentication) throws AccessDeniedException {
return authentication.isAuthenticated() &&
userDetailsService.authorize(req, authentication.getBaseDetails());
}
}핵심: 인가 실패는 여기서 바로 응답(403/정책 리다이렉트) 으로 전환되어, 이후 계층에 영향을 주지 않습니다.
인증/인가 판단 이후의 응답 포맷을 한 관문에서 일관 처리합니다.
public class AuthorizationTokenSuccessHandler implements TypedSuccessHandler<AuthorizationToken> {
@Override
public FilterChain doOnSuccess(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain,
AuthorizationToken authentication) throws IOException, ServletException {
userDetailsService.onLoginSuccess(authentication.getClient(), req, res, authentication.getBaseDetails());
res.sendRedirect(getNext(req, authentication));
return null;
}
}
public class LoginProcessingFailureHandler implements RequestMatcherFailureHandler {
@Override
public void onFailure(HttpServletRequest req,
HttpServletResponse res,
AuthenticationException authEx) throws IOException, ServletException {
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("{\"error\":\"unauthorized\",\"login\":\"/login.html\"}");
}
}
public class BasicAuthDeniedHandler implements RequestMatcherDeniedHandler {
@Override
public void onDenied(HttpServletRequest req,
HttpServletResponse res,
AccessDeniedException accessDenied) throws IOException, ServletException {
res.setStatus(HttpServletResponse.SC_FORBIDDEN);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("{\"error\":\"forbidden\",\"reason\":\"insufficient_role\"}");
}
}핵심: Fortel의 정책(YAML)에 맞춰 미인증/인가거부 응답 규약을 표준화합니다.
항목 | 기존 Spring Security | Fortel |
|---|---|---|
인증 설정 | Java DSL로 직접 구현 | YAML로 선언적 구성 |
여러 인증 방식 조합 | 수동 구현 및 커스텀 필터 필요 | Fortel이 자동으로 통합 관리 |
세션 정책 처리 |
|
|
인가 정책 관리(요청 단위) | 코드·어노테이션 기반 분산 관리 |
|
사내 공통 정책 적용 | 각 프로젝트별 중복 구현 | Fortel의 공통 모듈로 재사용 가능 |
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.formLogin()
.httpBasic();
return http.build();
}fortel:
request-matchers:
- pattern: "/api/**"
resources:
enabled: true
use-default: true
patterns:
- pattern: "/public/**"
# 세션/로그인/응답 정책은 fortel.session / fortel.login-processing / 통합 응답 핸들러로 관리Fortel은 사내 보안 진단 요구사항을 충족하며, 에이닷 오토 어드민 개발에 도입되어 사용되고 있습니다.
Fortel은 범용 오픈소스를 지향하기보다, 사내 시스템에 빠르고 안정적으로 그리고 일관되게 적용하기 위해 만든 라이브러리입니다.
이를 테크 블로그에 공개하는 것이 적절한지, 또 어느 수준으로 다루어야 할지 많은 고민이 있었습니다.
그래서 Fortel의 기능 설명이나 메커니즘 자체보다는, 무엇을 목표로 만들었는지와 대략적인 구성만으로 개발 스토리를 전하고자 했습니다.
Spring Security는 훌륭합니다. 업계 표준으로서 손색이 없고, 설계 사상 또한 교과서처럼 배울 점이 많습니다.
다만 그대로 억지로 끼워 맞추거나 다양한 기능을 모두 비활성화해 최소한으로만 쓰기보다, Spring Security를 훌륭한 교보재로 삼아 그 철학을 이해하고 조직에 맞는 보안 레이어를 스스로 구성하시길 권합니다.
그런 관점에서 Fortel은 “정답”이 아니라 한 가지 구현 예시입니다.
조직의 보안 정책을 정책 주도(Policy-Driven) 로 선언하고,
세션·인증·인가를 단일 관문 필터에서 일관되게 조율하며,
다중 인증 수단은 컨버터 체인으로 확장하고,
URL/메서드 중심의 요청 단위 인가와
성공/실패 통합 응답 처리로 운영 가시성을 높이는 방식입니다.
우리는 제2의 Fortel, 제3의 Fortel, 그리고 각자의 Fortel을 만들어 갈 수 있습니다.
Spring Security의 철학을 충분히 이해하고, 조직의 요구에 맞게 다듬어 활용한다면요.
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.