Spring Security
를 이용한OTP
인증 구현.- 구성
클라이언트
: 백엔드 서버에 요청을 보내는 주체 (테스트 코드로 대체 한다.)인증서버
: 사용자 자격을 증명하고 인증 토큰을 DB에서 조회(추후 SNS 서비스로 확장 가능)비지니스 논리 서버
: 노출될 API를 제공하는 애플리케이션 서버JWT
:Json Web Token
으로 토큰을 구성한다.
- 구성
클라이언트
⇒비지니스 논리 서버
⇒인증서버
⇒Database
- 나중에는 인증 서버가 클라이언트에
SNS
메세지를 보내고 해당 메세지를 통해 비지니스 서버에서 인증 처리
유효한OTP
정보면 클라이언트에TOKEN
을 발급한다.
- 나중에는 인증 서버가 클라이언트에
인증 서버 Entity
- User Entity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@Column(name = "username")
private String username;
@Setter
@Column(name = "password")
private String password;
@Builder
public User(String username, String password) {
this.username = username;
this.password = password;
}
}
- Otp Entity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Otp {
@Id
@Column(name = "username")
private String username;
@Setter
@Column(name = "code")
private String code;
@Builder
public Otp(String username, String code) {
this.username = username;
this.code = code;
}
}
인증 서버 Repository
1
2
3
4
5
6
7
public interface UserRepository extends JpaRepository<User, String> {
Optional<User> findUserByUsername(String username);
}
public interface OtpRepository extends JpaRepository<Otp, String> {
Optional<Otp> findOtpByUsername(String username);
}
인증 서버 Service
- Service
1
2
3
4
5
6
7
public interface UserService {
void addUser(User user);
String auth(User user);
boolean check(Otp otpToValidate);
}
- ServiceImpl
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final OtpRepository otpRepository;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public void addUser(User user) {
user.setPassword(passwordEncoded(user));
userRepository.save(user);
}
@Override
public String auth(User user) {
User findUser = userRepository.findUserByUsername(user.getUsername()).orElseThrow(() -> badCredentials().get());
log.info("findUser = {}", findUser.getUsername());
log.info("findUser = {}", findUser.getPassword());
if (passwordEncoder.matches(user.getPassword(), findUser.getPassword())) {
return renewOtp(findUser);
} else {
// throw badCredentials().get();
return null;
}
}
@Override
public boolean check(Otp otpToValidate) {
Optional<Otp> findOtp = otpRepository.findOtpByUsername(otpToValidate.getUsername());
if (findOtp.isPresent()) {
Otp otp = findOtp.get();
return otpToValidate.getCode().equals(otp.getCode());
}
return false;
}
private Supplier<BadCredentialsException> badCredentials() {
throw new BadCredentialsException("not found user. checked username or password");
}
private String passwordEncoded(User user) {
return passwordEncoder.encode(user.getPassword());
}
private String renewOtp(User findUser) {
String code = GenerateCodeUtil.generateCode();
Optional<Otp> findOtp = otpRepository.findOtpByUsername(findUser.getUsername());
if (findOtp.isPresent()) {
Otp otp = findOtp.get();
otp.setCode(code);
} else {
Otp otp = Otp.builder()
.username(findUser.getUsername())
.code(code)
.build();
otpRepository.save(otp);
}
return code;
}
private static final class GenerateCodeUtil {
public GenerateCodeUtil() {
}
private static String generateCode() {
String code = null;
try {
SecureRandom random = SecureRandom.getInstanceStrong(); // 난수 생성 방식이 예측하기 어려워서 보안적으로 유리하다.
int c = random.nextInt(9000) + 1000;
code = String.valueOf(c);
} catch (NoSuchAlgorithmException noSuchAlgorithmException) {
log.error("noSuchAlgorithmException = ", noSuchAlgorithmException);
}
return code;
}
}
}
인증 서버 Controller
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
32
33
34
35
36
37
38
39
40
41
42
43
44
@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {
private final UserService userService;
@PostMapping(path = "/user/add")
public ResponseEntity<Void> addUser(@RequestBody User user) {
userService.addUser(user);
return new ResponseEntity<>(HttpStatus.OK);
}
@PostMapping(path = "/user/auth")
public String auth(@RequestBody User user) {
String otpCode = userService.auth(user);
log.info("otp code = {}", otpCode);
return otpCode;
} // ToDO otp code 를 response 한다.
@PostMapping(path = "/otp/check")
public ResponseEntity<Void> check(@RequestBody Otp otp) {
if (userService.check(otp)) {
return new ResponseEntity<>(HttpStatus.OK);
} else {
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
}
}
@Getter
@Setter
@NoArgsConstructor
public static class RequestBodyContainer<T> {
private T requestBodyData;
public RequestBodyContainer(T requestBodyData) {
this.requestBodyData = requestBodyData;
}
}
}
인증 서버 Security Configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Slf4j
@Configuration
@RequiredArgsConstructor
public class ProjectConfig {
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.cors(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests((requests) -> requests
.anyRequest().permitAll()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
인증 서버 테스트 코드
테스트를 진행할때 전체 흐름을 확인해야 함으로
@Rollback(value = false)
설정
데이터 초기화를schema.sql
를 통한 초기화 진행application.yaml
spring.sql.init.mode= always
: 해당 부분을 설정하면resources
폴더 아래에schema.sql
파일을 프로젝트 실행할때 먼저 실행한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/otp_auth
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: root
jpa:
hibernate:
ddl-auto: none
show-sql: true
open-in-view: false
sql:
init:
mode: always
server:
port: 8522
schema.sql
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
CREATE DATABASE IF NOT EXISTS `otp_auth`; #
CREATE TABLE IF NOT EXISTS `user`
(
`password` varchar(255) DEFAULT NULL,
`username` varchar(255) NOT NULL,
PRIMARY KEY (`username`)
);
CREATE TABLE IF NOT EXISTS `otp`
(
`code` varchar(255) DEFAULT NULL,
`username` varchar(255) NOT NULL,
PRIMARY KEY (`username`)
);
DELETE
FROM `otp_auth`.`user`;
DELETE
FROM `otp_auth`.`otp`;
- 테스트 코드
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
@SpringBootTest
@AutoConfigureMockMvc
@Rollback(value = false)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class ControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private OtpRepository otpRepository;
private User user;
@BeforeEach
void setup(WebApplicationContext wac) {
mockMvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build();
user = User.builder()
.username("first_tester")
.password("1q2w3e4r!")
.build();
}
@Test
@Order(1)
void addUser() throws Exception {
mockMvc.perform(
post("/user/add")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(user))
)
.andExpect(status().isOk())
.andDo(print());
}
@Test
@Order(2)
void auth() throws Exception {
mockMvc.perform(
post("/user/auth")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(user))
)
.andExpect(status().isOk())
.andDo(print());
}
@Test
@Order(3)
void check_validate_happy() throws Exception {
Otp otp = findOtpHelper();
ResultActions perform = mockMvc.perform(
post("/otp/check")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(otp))
);
perform.andExpect(status().isOk());
}
@Test
@Order(4)
void check_validate_sad() throws Exception {
Otp otp = findOtpHelper();
otp.setCode("FAIL_CODE");
ResultActions perform = mockMvc.perform(
post("/otp/check")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(otp))
);
perform.andExpect(status().isForbidden());
}
private Otp findOtpHelper() {
Optional<Otp> findOtp = otpRepository.findOtpByUsername(user.getUsername());
Assertions.assertNotEquals(Optional.empty(), findOtp);
return findOtp.orElse(null);
}
}
- ToDo : 비니지스 논리 서버 구현