티스토리 뷰
이 포스팅은 이전 포스팅과 이어지므로 이전 포스팅을 먼저 보기를 권장한다.
이번 포스팅에서는 비즈니스 서버를 직접 구현해보자.
✨ 시리즈 구성
1. OTP 기반 2단계 인증 시스템 직접 구현해보기 - 1 시스템 아키텍처 및 3단계 인증 흐름
2. OTP 기반 2단계 인증 시스템 직접 구현해보기 - 2 인증 서버를 구현해보자.
3. OTP 기반 2단계 인증 시스템 직접 구현해보기 - 3 비즈니스 논리 서버를 구현해보자.
4. OTP 기반 2단계 인증 시스템 직접 구현해보기 - 4 인증 서버와 비즈니스 논리 서버가 잘 동작하는지 확인해보자.
5. OTP 기반 2단계 인증 시스템 직접 구현해보기 - 5 멀티 모듈 구조를 MSA 구조로 리팩토링해보자.
이번 포스팅에서는 JWT로 인증과 권한 부여를 수행하며 애플리케이션에서 MFA를 수립하기 위해 비즈니스 논리 서버와 인증 서버 간의 통신을 구현한다.
흐름은 다음과 같다.
1. 보호할 리소스에 해당하는 엔드포인트를 만든다.
2. 클라이언트가 사용자 자격 증명(사용자 이름 및 암호)을 비즈니스 논리 서버로 보내고 로그인하는 첫 번째 인증 단계를 구현한다.
3. 클라이언트가 인증 서버에서 사용자가 받은 OTP를 비즈니스 논리 서버로 보내는 두 번재 인증 단계를 구현한다.
OTP로 인증되면 클라이언트는 사용자의 리소스에 접근하는 데 필요한 JWT를 받는다.
4. JWT 기반 권한 부여를 구현한다. 비즈니스 논리 서버가 클라이언트에서 받은 JWT를 검증하고 올바르면 클라이언트가 리소스에 접근할 수 있게 허용한다.
먼저 아래와 같이 build.gradle에 의존성을 추가한다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.5'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'mysql:mysql-connector-java:8.0.33'
// security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
// JJWT (JSON 기반 JWT 처리)
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// JAXB
implementation 'jakarta.xml.bind:jakarta.xml.bind-api:3.0.1'
runtimeOnly 'org.glassfish.jaxb:jaxb-runtime:3.0.1'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
다음은 보호할 엔드포인트를 생성한다.
이 엔드포인트를 보호하기 위해 다음의 작업들을 수행한다.
보호할 리소스를 간단하게 /test로 구현했다.
TestController.java
@RestController
public class TestController {
@GetMapping("/test")
public String test() {
return "Test";
}
}
먼저 사용자 이름과 암호를 이용한 인증을 구현하는 UsernamePasswordAuthenticcation 클래스를 구현해보자.
UsernamePasswordAuthentication.java
public class UsernamePasswordAuthentication extends UsernamePasswordAuthenticationToken {
public UsernamePasswordAuthentication(Object principal, Object credentials) {
super(principal, credentials);
}
public UsernamePasswordAuthentication(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(principal, credentials, authorities);
}
}
위 코드는 Spring Security의 인증(Authentication) 객체인 UsernamePasswordAuthenticationToken을 상속해 구현했다.
Spring Security를 사용하다보면, 인증(Authentication) 객체를 커스터마이징해야할 때가 있는데,
예를 들어 UsernamePasswordAuthenticationToken를 확장해 내 비즈니스 로직에 맞는 인증 정보를 담고 싶을 때 사용한다.
Spring Security에서 인증(Authentication)은 Authentication 인터페이스로 표현한다.
이 객체는 다음과 같은 정보를 담고있다.
- principal: 사용자 정보 (보통 username 또는 UserDetails)
- credentials: 비밀번호 등 인증 자격 정보
- authorities: 권한 목록
- authenticated: 인증 여부
이 Authentication 인터페이스의 구현체가 UsernamePasswordAuthenticationToken이다.
아래는 Authentication 인터페이스의 코드이다.
Authentication.class
package org.springframework.security.core;
import java.io.Serializable;
import java.security.Principal;
import java.util.Collection;
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
아래는 UsernamePasswordAuthenticationToken 클래스이다.
UsernamePasswordAuthenticationToken.class
package org.springframework.security.authentication;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.util.Assert;
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 620L;
private final Object principal;
private Object credentials;
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super((Collection)null);
this.principal = principal;
this.credentials = credentials;
this.setAuthenticated(false);
}
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
public static UsernamePasswordAuthenticationToken unauthenticated(Object principal, Object credentials) {
return new UsernamePasswordAuthenticationToken(principal, credentials);
}
public static UsernamePasswordAuthenticationToken authenticated(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
return new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
}
public Object getCredentials() {
return this.credentials;
}
public Object getPrincipal() {
return this.principal;
}
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
Assert.isTrue(!isAuthenticated, "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
super.setAuthenticated(false);
}
public void eraseCredentials() {
super.eraseCredentials();
this.credentials = null;
}
}
UsernamePasswordAuthentication.java 클래스에서는 클래스를 간단하게 하기 위해 UsernamePasswordAuthenticationToken 클래스를 확장하고 Authentication 인터페이스도 간접적으로 확장했다.
이 클래스에서 생성자를 모두 정의한 이유는 다음과 같다.
매개변수가 두개인
public UsernamePasswordAuthentication(Object principal, Object credentials) {
super(principal, credentials);
};
위 생성자를 호출하면 인증 인스턴스가 인증되지 않은 상태로 유지되지만
public UsernamePasswordAuthentication(Object principal, Object credentials, Collection<? extends GrantAuthority> authorities) {
super(principal, credentials, authorities);
};
이 생성자를 호출하면 Authentication 객체가 인증된다.
Authentication 인스턴스가 인증되면 인증 프로세스가 완료됐음을 의미한다.
Authentication 객체가 인증 상태로 설정되지 않고 프로세스 중 예외가 투척되지 않았다면 AuthenticationManager는 요청을 인증할 올바른 AuthenticationProvider 객체를 찾으려고 한다.
세 번째 매개변수는 허가된 권한의 컬렉션이며 완료된 인증 프로세스에 필수다.
OTP를 이용한 두 번째 인증 단계를 위한 두 번재 Authentication 객체도 구현해보자.
OtpAuthentication.java
public class OtpAuthentication extends UsernamePasswordAuthenticationToken {
public OtpAuthentication(Object principal, Object credentials) {
super(principal, credentials);
}
public OtpAuthentication(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(principal, credentials, authorities);
}
}
다음으로 인증 서버가 노출하는 REST 엔드포인트를 호출하는 방법을 알바보자.
인증을 완료하려면 인증 서버를 호출하는 방법이 필요한데, 인증 서버에 대한 프락시(Proxy)를 구현해보자.
먼저, 인증 서버가 노출하는 REST 서비스를 호출하는 데 이용할 모델 클래스 User를 정의하자.
User.java
public class User {
private String username;
private String password;
private String code;
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public String getCode() {
return code;
}
public void setUsername(String username) {
this.username = username;
}
public void setPassword(String password) {
this.password = password;
}
public void setCode(String code) {
this.code = code;
}
}
다음 인증 서버가 노출하는 REST 엔드포인트를 호출하는 데 이용할 RestTemplate 형식의 빈을 선언한다.
SecurityConfig.java
@Configuration
public class SecurityConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
@Bean
public AuthenticationServerProxy authenticationServerProxy(
RestTemplate restTemplate,
@Value("${auth.server.base.url}") String baseUrl
) {
return new AuthenticationServerProxy(restTemplate, baseUrl);
}
}
다음으로 사용자 이름/암호 인증과 사용자 이름/otp 인증을 수행하는 메서드 두 개를 정의하는 프락시 클래스를 구현하자.
AuthenticationServerProxy.java
/**
* 외부 인증 서버와 통신하기 위한 클라이언트 역할의 프록시 클래스
* 역할 : RestTemplate을 이용해 인증 서버에 HTTP 요청 보냄.
* 애플리케이션의 직접 인증 로직을 구현하는 대신, 인증 서버의 API를 호출해 인증 작업 위임
*
*/
public class AuthenticationServerProxy {
private final RestTemplate restTemplate;
private final String baseUrl;
public AuthenticationServerProxy(RestTemplate restTemplate, @Value("${auth.server.base.url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
public void sendAuth(String username, String password) {
String url = baseUrl + "/user/auth";
var body = new User();
body.setUsername(username);
body.setPassword(password);
var request = new HttpEntity<>(body);
restTemplate.postForEntity(url, request, Void.class);
}
public boolean sendOTP(String username, String code) {
String url = baseUrl + "/otp/check";
var body = new User();
body.setUsername(username);
body.setCode(code);
var request = new HttpEntity<>(body);
var response = restTemplate.postForEntity(url, request, Void.class);
return response
.getStatusCode()
.equals(HttpStatus.OK);
}
}
application.properties 파일에 인증 서버의 기준 URL을 추가하자.
같은 시스템에서 두 서버 애플리케이션을 실행하므로 비즈니스 서버 애플리케이션의 포트를 변경해준다.
인증 서버는 기본 포트 8080을 유지하고 비즈니스 논리 서버의 포트는 9090으로 변경했다.
spring.application.name=business
server.port=9090
auth.server.base.url=http://localhost:8080
다음으로 AuthenticationProvider 클래스를 구현해보자.
우리는 두 단계의 인증을 진행할 것이므로, UsenamePasswordAuthentication을 수행할 UsernamePasswordAuthenticationProvider로 인증이 끝나지 않는다.
따라서 new UsernamePasswordAuthenticatonProvider(username, password)와 같이 매개 변수가 2개인 생성자로 Authentication 객체를 만든다.
매개변수가 2개인 UsernamePasswordAuthenticatonProvider 생성자는 객체가 검증되지 않았을 때 사용한다.
UsernamePasswordAuthenticationProvider.java
@Component
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
private final AuthenticationServerProxy proxy;
public UsernamePasswordAuthenticationProvider(AuthenticationServerProxy proxy) {
this.proxy = proxy;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = String.valueOf(authentication.getCredentials());
proxy.sendAuth(username, password);
return new UsernamePasswordAuthenticationToken(username, password);
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthentication.class.isAssignableFrom(authentication);
}
}
OtpAuthenticationProvider.java
@Component
public class OtpAuthenticationProvider implements AuthenticationProvider {
private final AuthenticationServerProxy proxy;
public OtpAuthenticationProvider(AuthenticationServerProxy proxy) {
this.proxy = proxy;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String code = String.valueOf(authentication.getCredentials());
boolean result = proxy.sendOTP(username, code);
if(result) {
return new OtpAuthentication(username, code);
} else {
throw new BadCredentialsException("Bad credentials");
}
}
@Override
public boolean supports(Class<?> authentication) {
return OtpAuthentication.class.isAssignableFrom(authentication);
}
}
OtpAuthenticationProvider는 인증 서버를 호출해 OTP가 맞는지 확인하는 로직이며,
OTP가 맞으면 Authentication 인스턴스를 반환하고 필터가 HTTP 응답에 토큰을 보낸다.
OTP가 틀리면 AuthenticationProvider가 예외를 던진다.
이제 맞춤형 FilterChain을 구현한다.
이 필터를 통해 요청을 가로채고 직접 구현한 인증을 적용한다.
인증 서버가 수행하는 인증을 처리할 필터 하나를 구현하고 JWT 기반 인증을 위해 다른 필터를 구현했다.
인증 서버가 수행하는 첫 번째 인증 단계를 처리할 InitialAuthenticationFilter 클래스를 구현하자.
InitialAuthenticationFilter.java
public class InitialAuthenticationFilter extends OncePerRequestFilter {
private final AuthenticationManager authenticationManager;
@Value("${jwt.signing.key}")
private String signingKey;
public InitialAuthenticationFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String username = request.getHeader("username");
String password = request.getHeader("password");
String code = request.getHeader("code");
if (code == null) {
Authentication auth = new UsernamePasswordAuthentication(username, password);
authenticationManager.authenticate(auth);
} else { // 두 번째 인증
Authentication auth = new OtpAuthentication(username, code);
auth = authenticationManager.authenticate(auth);
SecretKey key = Keys.hmacShaKeyFor(
signingKey.getBytes(
StandardCharsets.UTF_8
)
);
String jwt = Jwts.builder()
.setClaims(Map.of("username", username))
.signWith(key)
.compact();
response.setHeader("Authorization", jwt);
}
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getServletPath().equals("/login");
}
}
먼저 인증 책임을 위임할 AuthenticationManager를 주입하고,
요청이 FilterChain에서 이 필터에 도달할 때 호출되는 doFilterInternal() 메서드를 재정의하며, shouldNotFilter() 메서드를 재정의한다.
shouldNotFilter() 메서드는 Filter 인터페이스를 직접 구현하지 않고 OncePerRequestFilter 클래스를 확장해 구현했는데
이 메서드를 재정의할 때는 필터가 실행될 특정 조건을 정의한다.
/login 경로에 대해서만 모든 요청을 실행하고 나머지는 모두 건너뛴다.
첫번째 인증의 경우
Authentication auth = new UsernamePasswordAuthentication(username, password);
인스턴스를 만들고 책임을 AuthenticationManager에 전달해 첫 번째 인증 단계를 호출한다.
Otp 코드가 있는 두 번째 인증의 경우
OtpAuthentication 객체를 만들고 인증이 실패하면 예외가 던져진다.
OTP가 유효할 경우 JWT 토큰이 생성되고 HTTP 응답 헤더에 포함된다.
application.properties에 JWT를 서명하는 키 값을 추가해준다.
jwt.signing.key=${원하는JWT키값}
/login 외의 다른 모든 경로에 대한 요청을 처리하는 필터를 추가해야한다.
JwtAuthenticationFilter 클래스에 AuthenticationProvider의 HTTP 헤더에 JWT가 있다고 가정하고 서명을 확인해 JWT를 검증한 후 인증된 Authentication 객체를 만들고 이를 SecurityContext에 추가하는 코드를 작성하자.
JwtAuthenticationFilter.java
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Value("${jwt.signing.key}")
private String signingKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String jwt = request.getHeader("Authorization");
SecretKey key = Keys.hmacShaKeyFor(
signingKey.getBytes(StandardCharsets.UTF_8)
);
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jwt)
.getBody();
String username = String.valueOf(claims.get("username"));
GrantedAuthority authority = new SimpleGrantedAuthority("user");
var auth = new UsernamePasswordAuthentication(
username,
null,
List.of(authority)
);
SecurityContextHolder.getContext().setAuthentication(auth);
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return request.getServletPath().equals("/login");
}
}
이렇게 모든 부분이 구현되었다.
다음 포스팅에서는 시스템의 두 구성 요소인 인증 서버와 비즈니스 논리 서버를 실행하고 맞춤형 인증과 권한 부여가 예상대로 작동하는지 확인해보자.
'사이드 프로젝트 > 인증 시스템 구현' 카테고리의 다른 글
OTP 기반 2단계 인증 시스템 직접 구현해보기 - 5-2 Docker를 구현해보자. (0) | 2025.06.02 |
---|---|
OTP 기반 2단계 인증 시스템 직접 구현해보기 - 5-1 현재 프로젝트의 구조와 MSA로 전환하기 위해 어떤 과정이 필요한지 알아보자. (2) | 2025.06.01 |
OTP 기반 2단계 인증 시스템 직접 구현해보기 - 4 인증 서버와 비즈니스 논리 서버가 잘 동작하는지 확인해보자. (0) | 2025.05.27 |
OTP 기반 2단계 인증 시스템 직접 구현해보기 - 2 인증 서버를 구현해보자. (0) | 2025.05.25 |
OTP 기반 2단계 인증 시스템 직접 구현해보기 - 1 시스템 아키텍처 및 3단계 인증 흐름 (0) | 2025.05.24 |
- Total
- Today
- Yesterday
- Java
- 코틀린
- 알고리즘
- 정보처리 산업기사
- 자바
- 강의
- Spring Security
- challenges
- 정보처리산업기사
- 해커랭크 자바
- 자바의 정석
- 그리디
- hackerrank challenges
- 코드
- 정보처리산업기사 공부법
- ORM
- 풀이
- JPA
- 이코테
- 22 정보처리산업기사
- Kotlin
- 챌린지
- 22 정보처리 산업기사
- 디버깅
- 소스코드
- 해커랭크
- 백준
- 해커랭크 챌린지
- 해커랭크 자바 챌린지
- hackerrank
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 |