카테고리 없음

OTP 기반 2단계 인증 시스템 직접 구현해보기 - 2 인증 서버를 구현해보자.

dandev 2025. 5. 25. 19:27
반응형

이 포스팅은 이전 포스팅과 이어지므로 이전 포스팅을 먼저 보기를 권장한다.

이번 포스팅에서는 인증 서버를 직접 구현해보자.

✨ 시리즈 구성

1. OTP 기반 2단계 인증 시스템 직접 구현해보기 - 1 시스템 아키텍처 및 3단계 인증 흐름 

2. OTP 기반 2단계 인증 시스템 직접 구현해보기 - 2 인증 서버를 구현해보자.

3. OTP 기반 2단계 인증 시스템 직접 구현해보기 - 3 비즈니스 논리 서버를 구현해보자.

4. OTP 기반 2단계 인증 시스템 직접 구현해보기 - 4 인증 서버와 비즈니스 논리 서버가 잘 동작하는지 확인해보자.

5. OTP 기반 2단계 인증 시스템 직접 구현해보기 - 5 멀티 모듈 구조를 MSA 구조로 리팩토링해보자.

 

 

이 시나리오에서 인증 서버는 사용자 자격 증명과 요청 인증 이벤트 중에 생성된 OTP가 저장된 데이터베이스에 연결한다.

이 애플리케이션은 다음의 세 엔드포인트를 노출해야 한다.

  • /user/add - 구현을 테스트하기 위해 사용자를 추가하는 엔드포인트
  • /user/auth - 사용자를 인증하고 OTP가 포함된 SMS를 보낸다. 이 프로젝트에서는 SMS를 전송하는 과정은 생략한다.
  • /otp/check - OTP 값이 인증 서버가 특정 사용자를 위해 이전에 생성한 값인지 확인한다.

 


 

아래와 같이 build.gralde에 필요한 의존성을 추가한다.

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'

    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()
}

 


 

사용자 정보를 나타내는 User 테이블과 OTP를 관리하는 Otp 테이블을 생성하는 schema.sql을 등록한다.

CREATE TABLE IF NOT EXISTS `데이터베이스명`.`user` (
    `username` VARCHAR(45) NOT NULL,
    `password` TEXT NULL,
    PRIMARY KEY(`username`)
);

CREATE TABLE IF NOT EXISTS `데이터베이스명`.`user` (
    `username` VARCHAR(45) NOT NULL,
    `code` VARCHAR(45) NULL,
    PRIMARY KEY(`username`)
);

 


 

application.properties에 연결할 데이터베이스를 설정한다.

spring.application.name=auth
spring.datasource.url=jdbc:mysql://localhost:3306/${데이터베이스명}
spring.datasource.username=${user이름}
spring.datasource.password=${패스워드}

 


 

인증 서버의 구성 클래스를 작성해보자.

SecurityConfig.java

@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth -> auth
                        .anyRequest().permitAll()
                );
        return http.build();
    }
}

 


 

 

User와 Otp 엔티티를 생성하고 Repository를 생성한다.

 

User.java

@Entity
public class User {
    @Id
    private String username;
    private String password;

    public String getUsername() {
        return username;
    }

    public String getPassword() {
        return password;
    }
    
    public void setPassword(String password) {
        this.password = password;
    }
}

상용에서는 entity에 setter를 사용하는 것을 권장하지는 않지만 (엔티티 수정이 필요할 경우 builder 패턴 등을 사용함.)

예제에서는 getter, setter를 사용했다.

 

 

Otp.java

@Entity
public class Otp {
    @Id
    private String username;

    private String code;

    public String getUsername() {
        return username;
    }

    public String getCode() {
        return code;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public void setCode(String code) {
        this.code = code;
    }
}

 

 

UserRepository.java

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findUserByUsername(String username);
}

 

 

OtpRepository.java

public interface OtpRepository extends JpaRepository<Otp, String> {
    Optional<Otp> findOtpByUsername(String username);
}

 

 

 


 

다음으로 비즈니스 논리를 처리하기 위해 UserService.java를 생성한다.

@Service
@Transactional
public class UserService {
    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;
    private final OtpRepository otpRepository;

    public UserService(PasswordEncoder passwordEncoder, UserRepository userRepository, OtpRepository otpRepository) {
        this.passwordEncoder = passwordEncoder;
        this.userRepository = userRepository;
        this.otpRepository = otpRepository;
    }

    public void adduser(User user) {
        user.setPassword(passwordEncoder.encode(user.getPassword()));
        userRepository.save(user);
    }

    public void auth(User user) {
        Optional<User> o = userRepository.findUserByUsername(user.getUsername());

        if (o.isPresent()) {
            User u = o.get();

            boolean match = passwordEncoder.matches(user.getPassword(), u.getPassword());

            if (match) {
                renewOtp(u);
            } else {
                throw new BadCredentialsException("Bad Credentials"); // 암호 틀림.
            }
        } else {
            throw new BadCredentialsException("Bad Credentials"); // 사용자 없음.
        }
    }

    private void renewOtp(User u) {
        String code = GenerateCodeUtil.generateCode();

        Optional<Otp> userOtp = otpRepository.findOtpByUsername(u.getUsername());
        if (userOtp.isPresent()) { // 사용자 이름에 대한 OTP가 있으면 값 업데이트
            Otp otp = userOtp.get();
            otp.setCode(code);
        } else { // 사용자 이름에 대한 OTP가 없으면 생성된 값으로 레코드 생성
            Otp otp = new Otp();
            otp.setUsername(u.getUsername());
            otp.setCode(code);
            otpRepository.save(otp);
        }
    }

    public boolean check(Otp otpToValidate) {
        Optional<Otp> userOtp = otpRepository.findOtpByUsername(otpToValidate.getUsername());

        if (userOtp.isPresent()) {
            Otp otp = userOtp.get();

            if (otpToValidate.getCode().equals(otp.getCode())) {
                return true;
            }
        }

        return false;
    }
}

 

코드에 대한 설명은 주석으로 간단히 남겨두었다.

auth() 메서드는 DB에서 사용자 정보를 조회해 사용자가 존재하면 Otp를 업데이트하고

사용자 정보가 존재하지 않으면 Otp를 생성한다.

 

cheeck() 메서드는 username으로 otp를 조회해 값을 비교해 유효를 체크한다.

 

아 참고로

auth() 메서드의 Exception message가 "Bad Credentials."인 이유는

보안상으로 에러 메시지를 구체적으로 적지 않기 위함이다.

 

실제 서비스에서도 

"비밀번호가 틀렸습니다."

"아이디가 틀렸습니다." 이렇게 구체적으로 노출 시켜주면 두 값 중 하나는 일치하다는 것을 증명하는 것이므로

보통 "아이디 또는 비밀번호가 틀렸습니다."라고 노출한다고 한다.

 


 

다음은 Otp를 생성하는데 사용했던 GenerateCodeUtil 클래스를 작성해보자.

 

GenerateCodeUtil.java

public class GenerateCodeUtil {

    private GenerateCodeUtil() {}

    public static String generateCode() {
        String code;

        try {
            SecureRandom random = SecureRandom.getInstanceStrong();

            int c = random.nextInt(9000) + 1000;

            code = String.valueOf(c);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("Problem when generating the random code.");
        }
        return code;
    }
}

임의의 int 값을 생성하는 SecureRandom 인스턴스를 만들어

0~8,999 사이의 값을 생성하고 1,000을 더해 1,000~9,999 사이의 4자리 임시 코드를 얻는다.

 


 

다음으로 Controller를 작성해보자.

AuthController.java

@RestController
public class AuthController {
    private final UserService userService;

    public AuthController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/user/add")
    public void addUser(@RequestBody User user) {
        userService.adduser(user);
    }

    @PostMapping("/user/auth")
    public void auth(@RequestBody User user) {
        userService.auth(user);
    }

    @PostMapping("/otp/check")
    public void check(@RequestBody Otp otp, HttpServletResponse response) {
        if (userService.check(otp)) {
            response.setStatus(HttpServletResponse.SC_OK);
        } else {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        }
    }
}

 

 


 

이제 인증 서버가 구현됐다.

서버를 시작하고 예상대로 작동하는지 확인해보자.

흐름은 다음과 같다.

 

1. /user/add 엔드포인트를 호출해 사용자를 데이터베이스에 추가한다.

2. 데이터베이스의 user 테이블을 확인해 사용자가 추가되었는지 확인한다.

3. 1에서 추가한 사용자로 /user/auth 엔드포인트를 호출한다.

4. 애플리케이션이 OTP를 생성하고 otp 테이블에 저장하는지 확인한다.

5. 3에서 생성된 OTP로 /otp/check 엔드포인트를 호출해 원하는 대로 작동하는지 확인한다.

 

 

1. /user/add를 요청한다.

POST /user/add 요청

 

 

2. 다음과 같이 user 테이블에 user가 추가된 것을 확인했다.

user 테이블에 testuser가 등록됨.

보이는 것과 같이 passwordEncoder를 통해 암호를 암호화되어 저장되었다.

 

3. 등록한 testuser로 /user/auth 엔드포인트를 호출한다.

등록한 user로 /user/auth 요청

 

4. otp 테이블에 해당 유저의 otp가 등록되었다.

otp가 정상적으로 등록되었다.

 

 

5. 생성된 otp로 인증이 정상적으로 작동하는지 확인한다.

인증이 정상적으로 동작하는 것을 확인할 수 있다.

 

이번에는 잘못된 otp를 요청하여 인증이 실패하는지도 확인해보자.

잘못된 인증 요청(code:1234)으로 인증에 실패했다.

 

 

이것으로 인증 서버 구성 요소가 작동한다는 것을 확인했다.

그럼 다음 포스팅에서는 가장 많이 작성하는 요소인 비즈니스 논리 서버를 구현해보자.

 

 

참고 : Spring Security in Action 

반응형