package com.randomchat.main.controller.register;
import com.randomchat.main.dto.register.EmailVerificationDTO;
import com.randomchat.main.service.email.EmailVerificationService;
import com.randomchat.main.service.register.RegisterService;
import jakarta.mail.MessagingException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
@RequiredArgsConstructor
@RestController
@RequestMapping("/register")
public class EmailVerificationController {
private final EmailVerificationService emailVerificationService;
private final RegisterService registerService;
@PostMapping("/email/verification")
public ResponseEntity<String> emailVerification(@RequestBody EmailVerificationDTO emailVerificationDTO) throws MessagingException, IOException {
String email = emailVerificationDTO.getEmail();
// 1. 이메일 인증이 들어온 이메일 정보로 가입된 계정이 있다면 deny
if(registerService.checkEmailDuplication(email)) return ResponseEntity.status(409).body("이미 가입된 이메일입니다.");
// 2. 이메일 인증이 들어온 기준 시간으로 부터 5분 내로 DB 내에 5회 인증 요청이 있다면 deny
// emailVerificationService.esExceededLimit(email);
// 3. 이메일 인증용 난수 생성
String verificationCode = emailVerificationService.createCode();
// 4. 이메일을 발송한 뒤 이메일 인증 데이터 DB 에 저장
emailVerificationService.sendEmail(email, verificationCode); // 인증 이메일 발송
emailVerificationService.saveEmailVerification(email, verificationCode); // DB 에 인증 내용 저장
return ResponseEntity.ok("이메일이 발송되었습니다.");
}
}
2. RegisterService ( JPA 쿼리 메소드를 사용하여 동일한 메일이 있는지 체크 )
package com.randomchat.main.service.register;
import com.randomchat.main.domain.users.Users;
import com.randomchat.main.dto.register.RegisterDTO;
import com.randomchat.main.repository.UsersRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class RegisterService {
private final UsersRepository usersRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
public void register(RegisterDTO registerDTO) {
String email = registerDTO.getEmail();
String password = registerDTO.getPassword();
// 받아온 비밀번호 정보를 암호화
registerDTO.setPassword(bCryptPasswordEncoder.encode(registerDTO.getPassword()));
Users users = new Users();
Users user = users.createUser(registerDTO);
usersRepository.save(user);
}
public boolean checkEmailDuplication(String email) {
return usersRepository.existsByEmail(email);
}
public boolean checkNicknameDuplication(String nickname) {
return usersRepository.existsByNickname(nickname);
}
}
3. emailVerificationService
- createCode 메소드 -
1) 난수에 사용될 문자들을 미리 선언 후 RANDOM 클래스의 nextInt 메소드를 사용하여 문자 길이만큼의 수 내에서 랜덤 숫자를 생성
2) 랜덤 숫자를 다시 CHARACTERS 문자열의 인덱스로 접근하여 랜덤한 문자를 뽑아 StringBuilder 에 추가한 뒤 반복문이 다 돌면 빌드
- renderJspToString 메소드 -
1) templates 디렉토리에 있는 jsp 파일을 불러와 Resource 객체로 생성
2) 생성된 객체의 정보를 UTF-8 으로 인코딩된 String 으로 변환
3) String 으로 변환된 내용 중 ${verificationCode} 문자를 실제 인증 코드로 변경
- sendEmail 메소드 -
1) 이메일을 전송하기 위해 Mimemessage 객체를 생성
2) Mimemessage 를 쉽게 설정하도록 도와주는 헬퍼 클래스인 MimemessageHelper 클래스를 선언하여 다음과 같이 설정
true : HTML 형식을 지원하도록 설정
UTF-8 : 이메일 내용의 인코딩 방식을 설정
3) 헬퍼 플래스를 사용하여 이메일 주소, 제목, 내용을 다음과 같이 설정
setTo : 수신자 이메일 작성
setSubject : 메일 제목 설정
setText : 메일 본문을 설정, true 로 HTML 형식 적용
setFrom : 발신자의 이메일 주소를 설정
package com.randomchat.main.service.email;
import com.randomchat.main.domain.email.EmailVerification;
import com.randomchat.main.repository.EmailVerificationRepository;
import com.randomchat.main.repository.UsersRepository;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.SecureRandom;
import java.util.Random;
@Service
@RequiredArgsConstructor
public class EmailVerificationService {
private final UsersRepository usersRepository;
private final EmailVerificationRepository emailVerificationRepository;
private final ResourceLoader resourceLoader;
private final JavaMailSender mailSender;
private static final Random RANDOM = new SecureRandom();
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; // 난수에 사용될 문자
public String createCode() {
StringBuilder stringBuilder = new StringBuilder();
// 10 자리 난수를 생성할 것이므로 10회 반복
for (int i = 0; i < 10; i++) {
// 난수에 사용될 문자의 길이만큼의 랜덤한 값을 추출
int randomIndex = RANDOM.nextInt(CHARACTERS.length());
// 랜덤한 index 의 문자를 추출하여 문자열로 추가
stringBuilder.append(CHARACTERS.charAt(randomIndex));
}
return stringBuilder.toString();
}
public void saveEmailVerification(String email, String verificationCode) {
EmailVerification emailVerification = new EmailVerification();
EmailVerification createEmailVerification = emailVerification.createEmailVerification(email, verificationCode);
emailVerificationRepository.save(createEmailVerification);
}
public String renderJspToString(String verificationCode) throws IOException {
// JSP 파일 내용을 String 으로 읽어오기
Resource resource = resourceLoader.getResource("classpath:/templates/emailForm.jsp");
String content = new String(Files.readAllBytes(resource.getFile().toPath()), StandardCharsets.UTF_8);
// 인증 코드를 내용에 삽입하여 리턴
return content.replace("${verificationCode}", verificationCode);
}
public void sendEmail(String email, String verificationCode) throws IOException, MessagingException {
String emailContent = renderJspToString(verificationCode);
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(email);
helper.setSubject("랜덤 채팅의 이메일 인증 번호입니다.");
helper.setText(emailContent, true); // HTML 형식으로 보낼 때 true 설정
helper.setFrom("youngho3358@gmail.com");
mailSender.send(message);
}
}
사용자가 Token 을 가지고 API 의 엔드포인트에 접근했을때 Security 에서 JWTUtil 클래스를 거쳐 유저가 정상적인 토큰을 가지고 있는지 검증한 뒤 해당 Token 의 값을 기준으로 User 의 정보를 가져와 다른 클래스에서 필요할때 사용하고자 하였다.
문제 발생)
Token 에서 유저의 정보를 가져오는 방법으로는
1) payload 값에 유저에 대한 정보를 모두 넣어 서버에서 디코딩하여 확인하는 방법
2) SecurityContextHolder 에서 검증된 유저의 email 정보를 가지고 DB 를 한번 훑어서 유저의 정보를 가져오는 방법
위 두가지 방법이 존재하는 것으로 파악하였다, 위 두 가지 방법 중 나는 후자를 선택하여 구현하고자 하였는데 이유로는 사용자의 Local Strage 의 Token 정보가 항상 동기화 되어있지 않을 수 있다는 문제점이 있기 때문에 확실하게 DB 를 한번 거쳐서 동기화된 사용자의 정보를 출력하여 사용하고자 하였다.
또한 전자로 구현하는 경우 Token 정보를 받아서 처리해야 하기 때문에 HttpServletRequest 객체를 매개변수로 매번 넘겨야하는 번거로움이 있는 반면 후자로 구현하고자 하면 매개변수를 넘기지 않고도 Spring Security 에서 인증된 사용자에 대한 객체 정보를 저장하고 있는 SecurityContext 를 활용하여 유저에 대한 정보를 확인할 수 있기 때문에 코드가 간결해진다는 장점이 있다...!!
JWT 내부의 payload 에 Role 값을 key, value 로 지정한 후 클라이언트에서 해당 JWT 를 가져와 서버로 검증할때 해당 유저의 정보를 가져와 DB 에서 값을 추출하여 접근 api 주소에 권한이 있는지 확인하여 처리하도록 구현하고자 하였다.
문제 발생 )
1. 우선 현재 프로젝트에서 관리하는 Role 값은 enum 타입으로 설정하여 관리중이고 Spring Security 에서는 GrantedAuthority 객체나 SimpleGrantedAuthority 객체를 사용하여 객체 값을 관리하므로 관리하는 객체 타입이 다른 점에서 발생할 수 있는 문제가 있다고 판단하였다.
2. 토큰이 정상정으로 발급됨을 확인한 뒤 HttpSecurity 클래스의 hasRole("USER") 메소드나 @PreAuthrize("hasAnyRole('USER')") 어노테이션을 사용하여 분명 권한 정보를 토큰에서 추출하여 비교한 뒤 검증해야하는데 모든 요청의 응답이 403 Forbidden 로 처리되는 문제가 발생했다.
문제 해결 )
1. Role 값의 타입 문제는 토큰을 구현하며 공식문서와 인터넷 서핑을 통해 찾아보니 어짜피 JWT 에 담기는 모든 내용은 문자열로 변환하여 저장되고 검증되므로 토큰을 구현하는데 있어서 현재 사용하는 클래스 타입에서 캐스팅을 통해 문자열로 변환한 뒤 저장해주면 된다는 해결책을 찾게 되었다.
2. 가장 큰 문제점이었던 권한이 정상적으로 검증되지 않았던 문제는 우선적으로 토큰에 정상적인 권한 값이 들어가있는지 토큰의 정보를 추출하여 확인해보아도 내가 설정한 USER 값이 정상적으로 출력되는 것을 확인할 수 있었다. 그래서 공식문서를 찾아보니 hasRole 을 사용하여 권한을 검증하면 ROLE_ 의 접두가사 권한의 앞에 붙는다는 사실을 알게되었고 유저의 Role 값을 검증함에 있어서 앞에 접두사를 붙여서 코드를 구현하니 정상적으로 200 OK 응답이 돌아오는 것을 확인하였다.
구현 목표 ) Spring Security 와 JWT(Json Web Token) 을 활용하여 로그인 시 토큰을 발행하고 프론트에서 넘어오는 요청을 토큰의 내부 payload 값을 확인하여 처리하는 로직을 구현하고자 하였다.
문제 발생 )Spring Security 의 인가 구현 방식은 UsernamePasswordAuthenticationFilter 클래스가 form-data 형식의 데이터를 받아 처리하게끔 구현되어 있다, 하지만 현재 구현하고 있는 프로젝트의 경우 요청을 form-data 형식이 아닌 Json 데이터로 처리하기로 규약하였고 이를 위해 해당 클래스를 상속받아 커스텀 처리를 진행할 필요가 있었다.
문제 해결 ) UsernamePasswordAuthenticationFilter 클래스에서 로그인 정보를 처리하는 obtainEmailAndPassword 메소드를 선언하여 HttpServletRequest 로 요청된 form-data 의 이메일, 비밀번호가 담긴 로그인 정보를 json 형태로 파싱하여 오버라이딩할 attemptAuthentication 메소드에 로그인 정보를 DTO 형태로 전달하여 사용하게끔 코드를 작성하였다.
보완점) 기본적으로 Spring Security 에서 로그인을 검증할때 사용되는 방식이 form-data 방식이다보니 추가적으로 데이터 검증이 필요하거나 토큰을 발급하는 데 있어서 많은 클래스 커스텀이 필요할 것으로 보인다, 또한 기존의 Spring Security 는 username 과 password 라는 변수 명으로 로그인 처리를 진행하게 되는데 현재 구현하고자 하는 프로젝트에서는 email 과 password 값으로 로그인을 진행할 것이므로 email 값을 받아 DB 와 검증 절차를 거치게 하는 로직의 추가 구현이 필요하다.
( User 테이블을 JPA 를 통해 설계하고 실행시키는 순간 User 테이블을 생성하는 쿼리에서 Syntax error 가 발생한 것을 확인 )
Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Syntax error in SQL statement "create table [*]user (id bigint not null, email varchar(255) not null, gender enum ('FEMALE','MALE'), nickname varchar(255) not null, password varchar(255) not null, role varchar(255) not null, primary key (id))"; expected "identifier"; SQL statement:
> 테이블 생성 코드 자체에는 잘못된 부분이 없는 것을 확인, 해당 문제를 해결하기 위해 구선생님께 "jpa 테이블 생성 실패", "jpa JdbcSQLSyntaxErrorException" 등을 검색해보다가 한 가지 사실을 알게되었다...!
2. SQL 표준에는 USER 가 예약어로 설정되어 있다.
( SQL 쿼리에는 USER 라는 예약어가 설정되어 있으며, SELECT USER() 와 같은 쿼리를 실행시키면 현재 접속중인 사용자의 이름을 반환하게 된다 )
> Oracle, MySQL 등의 쿼리에서는 예약어를 유연하게 처리할 수 있게끔 설정되어 있으나 H2 Database, postgresql, ms-sql 등 데이터베이스에서는 예약어에 대한 처리과정이 더 엄격하게 설정되어 있어 USER 라는 테이블을 생성하지 못한다고 한다.
3. 테이블 명을 USERS, MEMBER 등으로 변경하여 생성하기
Hibernate: create table users (id bigint not null, email varchar(255) not null, gender enum ('FEMALE','MALE'), nickname varchar(255) not null, password varchar(255) not null, role varchar(255) not null, primary key (id))
미국 여행을 1달 넘게 다녀오면서 개발에 소홀해진 느낌이 들어 오늘부터 TIL(Today I Learned) 포스트를 기재해보려고 한다.
( 포트폴리오와 이력서를 작성하느라미국 여행도 정신이 없이 지나가버린 듯 하다.... )
국비교육 과정에서 함께 프로젝트를 한번 진행했던 팀원분과 같이 사이드 프로젝트로 랜덤 채팅을 구현하고 서비스해보기로 했다.
기본적으로 Login 기능과 Register 기능은 필수로 구현하기로 하였고 우선적으로 구현할 부분은 랜덤한 상대와 텍스트 채팅을 먼저 구현하고 텍스트 채팅 구현이 완료되면 영상통화도 같이 구현해보기로 하였다.
- TIL -
1. 처음 Spring 프로젝트를 생성하고 빌드
( 프로젝트를 구현하기 위해 추가된 의존성 : Lombok, Spring Web, Spring Security, Spring Data JPA, MySQL Driver, Spring Boot DevTools, H2 Database )
2. H2 Database 를 in-memory 를 사용하게끔 설정하여 주 메모리에 Database 를 활성화 하여 사용하고자 하였고 좌측 같이 설정 후 실행하여 H2 콘솔 주소(http://localhost:8080/h2-console) 로 접근하니 우측과 같이 Spring Security 에 설정된 Username / Password 를 요구하는 창이 출력되었다
( 나는 Security 에서 제공하는 폼 로그인 기능을 사용하지 않을 것이지만... 혹시 모르니 해당 주소만 인가를 얻을 수 있는 방법을 검색해보았다 )
3. 검색을 통해 알아낸 바에 의하면 Spring Security 는 자동으로 SecurityFilterChain 빈을 찾아 해당 애플리케이션 요청에 대해 보안 필터 체인을 적용한다고 하는데 나는 이 SecurityFilterChain 부분을 구현하는 방법을 찾아 기본적인 폼 로그인 방법을 사용하지 않게끔 설정하기로 하였다.
4. 돌아왔구나 H2 태식이....
- 의문점 및 보완점 -
1. Restful API 서버를 구축하는데 있어서 인가 부분을 오롯이 토큰에만 의존하여 설계해도 되는지 의문이 생긴다
( 예를 들어, 토큰에 role 값을 payload 에 담아서 사용한다고 가정했을때 이 권한 부분이 변조될 위험성은 없는가? )