프로젝트 전체 코드: https://github.com/kcm02/JWT_OAuth_Login.git
이메일 인증은 애플리케이션에서 사용자가 제공한 이메일 주소가 유효한지 확인하고, 계정의 소유자가 실제 사용자임을 보장하기 위한 과정을 말한다.
이미 많은 사이트에서 사용하고 있지만, 특히 요즘엔 인증번호가 아닌 링크 클릭을 통해 링크를 클릭하면 바로 인증되는 방식을 주로 사용한다.
사용자 요청: 사용자가 회원 가입이나 정보 수정 등을 요청하면, 서버는 사용자가 제공한 이메일 주소로 인증 이메일을 전송한다.
인증 토큰 생성: 서버는 고유한 인증 토큰을 생성하고 이를 데이터베이스에 저장한 후 이메일에 포함시킨다.
인증 이메일 전송: 인증 토큰이 포함된 이메일을 사용자에게 전송한다. 이메일에는 토큰을 포함한 인증 링크가 포함된다.
인증 링크 클릭: 사용자는 이메일을 열어 인증 링크를 클릭한다. 이 링크는 서버의 특정 엔드포인트를 호출하게 된다.
토큰 검증 및 계정 활성화: 서버는 링크에 포함된 토큰을 검증하고, 유효한 토큰이면 사용자의 계정을 활성화한다.
이처럼 이메일 인증은 휴대폰 인증과 더불어 웹 애플리케이션 보안에서 필수적인 요소이므로 구현법을 제대로 알고 있어야 한다. 이제부터 구현해 보자!
com.project.securelogin
├── config
│ └── RedisConfig.java
│ └── SecurityConfig.java ✔️
├── controller
│ └── AuthController.java
│ └── UserController.java ✔️
├── domain
│ └── CustomUserDetails.java
│ └── User.java ✔️
├── dto
│ └── JsonResponse.java
│ └── UserRequestDTO.java
│ └── UserResponseDTO.java
├── jwt
│ └── JwtAuthenticationFilter.java
│ └── JwtTokenProvider.java
├── repository
│ └── JwtTokenRedisRepository.java
│ └── UserRepository.java ✔️
└── service
└── AuthService.java
└── CustomUserDetailsService.java
└── MailService.java ✔️
└── UserService.java ✔️
├── resources ☑️ (yml 구조 변경)
└── application.yml
└── application-db.yml
└── application-jwt.yml
└── application-mail.yml
SecurityConfig
: 이메일 인증 엔드포인트를 permitAll()
설정
User
: mailVerificationToken
필드 추가
MailService
: 이메일 전송 기능을 구현하는 서비스 클래스
UserService
: 회원가입, 정보 수정시 이메일을 전송하는 로직 추가
UserController
: 이메일 인증 엔드포인트 추가
application.yml
, *.yml
: 설정에 따라 파일 분리, .gitignore
적용
이메일 인증 기능에서 필요한 의존성을 pom.xml
의 dependencies
에 추가한다.
<dependencies>
··· 생략 ···
<!-- JavaMailSender -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
··· 생략 ···
</dependencies>
JavaMailSender
: 스프링 프레임워크에서 제공하는 인터페이스로, 이메일을 전송하기 위해 사용된다. 텍스트 이메일 형태부터 복잡한 형태의 이메일까지 폭넓게 지원한다.yml
application.yml
server:
port: 9090
spring:
profiles:
group:
local: db, jwt, mail # 기능 분리
active: local
application:
name: secure-login
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect
기존에는 application.yml
안에 모든 설정 내용이 포함되었지만 기능이 확장되고 설정 파일이 복잡해짐에 따라 기능별로 파일을 분리하게 되었다.
yml
파일을 분리할 때에는 group
에 다른 설정 파일들을 꼭 명시해야 한다.
application-db.yml
# DB 관련 설정 파일
spring:
datasource:
url: jdbc:mysql://localhost:3306/secure_login
username: # [db username]
password: # [db password]
driver-class-name: com.mysql.cj.jdbc.Driver
data:
redis:
host: localhost
port: 6379
MySQL
, Redis
와 같은 데이터베이스 설정을 이 파일에 포함시킨다.
application-jwt.yml
# JWT 관련 설정 파일
jwt:
secret: # [secret key]
token-validity-in-seconds: 1800 # 유효 기간: 30분
refresh-token-validity-in-seconds: 86400 # 유효 기간: 하루
JWT
설정에 대한 내용은 전 게시물을 참고하면 된다.
application-mail.yml
# JavaMailSender 관련 설정
spring:
mail: # 이메일을 전송할 SMTP 서버 설정
host: smtp.gmail.com
port: 587
username: # [호스트 메일 주소]
password: # [비밀번호]
properties:
mail:
smtp:
auth: true # SMTP 서버에 로그인할 때 인증을 사용할지를 결정
starttls:
enable: true # TLS를 사용할지를 결정
debug: true # 디버그 모드 활성화 (선택 사항)
이 설정은 스프링 부트 애플리케이션이 Gmail SMTP
서버를 사용하여 이메일을 전송할 수 있도록 구성한다. 이때, 서버는 Naver
등을 사용해도 상관 없다.
인증과 보안을 위해 *TLS를 사용하며, 필요한 경우 디버그 모드를 활성화하여 이메일 전송 과정을 모니터링 할 수 있다.
💡 TLS(Transport Layer Security)란?
TLS(전송 계층 보안)은 인터넷 상의 커뮤니케이션을 위한 개인 정보와 데이터 보안을 용이하게 하기 위해 설계되어 채택된 보안 프로토콜이다. TLS의 주요 사용 사례는 웹 응용 프로그램과 서버 간 커뮤니케이션을 암호화하는 것이다. TLS는 또한 이메일, 메시지 등을 암호화하기 위해 사용되기도 한다.
.gitignore
에 적용하기### YML 파일 제외 ###
*.yml
!application.yml
보안을 위해 application.yml
을 제외한 모든 yml
파일을 Git
저장소에서 제외한다. .gitignore
에 대한 자세한 내용은 [GitHub] .gitignore 적용하기를 참고하면 된다.
SecurityConfig
.requestMatchers("user/verify/**").permitAll()
후에 UserController
에서 사용할 이메일 인증 엔드포인트를 permitAll()
해준다.
만약 permitAll()
처리를 하지 않으면 인증 전 사용자는 비활성화 상태이므로 링크에 접속해도 권한이 없는 상태로 판단되어 Spring Security
에 의해 가로막힐 것이다.
User
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
public class User {
··· 생략 ···
private boolean enabled; // 계정 활성화 여부
private String mailVerificationToken; // 이메일 인증 토큰
public void updateUser(UserRequestDTO requestDTO, PasswordEncoder passwordEncoder,String mailVerificationToken) {
this.username = requestDTO.getUsername();
this.email = requestDTO.getEmail();
this.mailVerificationToken = mailVerificationToken;
this.enabled = false;
// 새로운 비밀번호가 null이 아니고, 기존 비밀번호와 다를 때만 인코딩하여 업데이트
if (requestDTO.getPassword() != null && !requestDTO.getPassword().equals(this.password)) {
this.password = passwordEncoder.encode(requestDTO.getPassword());
}
}
public void enableAccount() {
this.enabled = true;
this.mailVerificationToken = null;
}
}
enabled
: 계정 활성화 여부 (true
= 활성화, false
= 비활성화)
mailVerificationToken
: 이메일 인증 토큰
updateUser()
UserRequestDTO
PasswordEncoder
mailVerificationToken
requestDTO
를 받아 회원 정보를 업데이트한다. 또한 if
문을 통해 조건에 부합하는 경우에만 비밀번호를 인코딩하여 업데이트한다.mailVerificationToken
에 넣는다.enabled = false
로 설정한다.enableAccount()
enabled = true
한다.mailVerificationToken
은 null
값을 넣어준다.MailService
// 이메일 전송 기능을 구현하는 서비스 클래스
@Service
@RequiredArgsConstructor
public class MailService {
private final JavaMailSender javaMailSender;
public void sendEmail(String to, String verificationUrl, String subject) {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(to);
message.setSubject(subject);
message.setText("이메일 인증을 완료하려면 아래 링크를 클릭하세요: " + verificationUrl);
javaMailSender.send(message);
}
}
sendEmail()
to
: 수신자 이메일 주소verificationUrl
: 이메일 인증 링크 URLsubject
: 이메일 제목JavaMailSender
)를 통해 메시지를 보낸다.UserSerivce
@Service
@RequiredArgsConstructor
@Transactional
public class UserService {
··· 생략 ···
private final MailService mailService;
// 회원 가입
public UserResponseDTO signUp(UserRequestDTO userRequestDTO) {
validateEmail(userRequestDTO.getEmail()); // 이메일 중복 체크
String encodedPassword = encodePassword(userRequestDTO.getPassword());
String verificationToken = UUID.randomUUID().toString();
User user = User.builder()
.username(userRequestDTO.getUsername())
.password(encodedPassword)
.email(userRequestDTO.getEmail())
.accountNonExpired(true)
.accountNonLocked(true)
.credentialsNonExpired(true)
.enabled(false)
.mailVerificationToken(verificationToken)
.build();
sendEmail(user.getEmail(), verificationToken, "회원가입 이메일 인증");
userRepository.save(user);
return new UserResponseDTO(user.getUsername(), user.getEmail());
}
// 이메일 검증
public UserResponseDTO verifyEmail(String token) {
User user = findUserByVerificationToken(token);
user.enableAccount(); // 엔티티 메서드 사용
userRepository.save(user);
return new UserResponseDTO(user.getUsername(), user.getEmail());
}
// 회원 정보 수정
public UserResponseDTO updateUser(Long userId, UserRequestDTO userRequestDTO) {
return userRepository.findById(userId).map(user -> {
if (!user.getEmail().equals(userRequestDTO.getEmail())) {
validateEmail(userRequestDTO.getEmail());
}
String verificationToken = UUID.randomUUID().toString();
user.updateUser(userRequestDTO, passwordEncoder,verificationToken);
sendEmail(user.getEmail(), verificationToken, "회원 정보 수정용 이메일 인증");
userRepository.save(user);
return new UserResponseDTO(user.getUsername(), user.getEmail());
}).orElseThrow(() -> new IllegalStateException("사용자를 찾을 수 없습니다."));
}
// 이메일 전송 메서드
private void sendEmail(String email, String verificationToken, String subject) {
String verificationUrl = "http://localhost:9090/user/verify/" + verificationToken;
mailService.sendEmail(email, verificationUrl, subject);
}
// 이메일 토큰을 사용하여 사용자 조회
private User findUserByVerificationToken(String token) {
return userRepository.findByMailVerificationToken(token)
.orElseThrow(() -> new IllegalStateException("유효한 토큰이 없습니다."));
}
··· 생략 ···
}
signUp()
userRequestDTO
: 회원 가입 요청 정보를 담은 DTO 객체verifyEmail()
token
: 이메일 인증 토큰updateUser()
userId
: 수정할 회원의 IDuserRequestDTO
: 수정할 회원 정보를 담은 DTO 객체findUserByVerificationToken()
token
: 이메일 인증 토큰UserController
// 이메일 인증
@GetMapping("/verify/{token}")
public ResponseEntity<JsonResponse> verifyEmail(@PathVariable String token) {
UserResponseDTO userResponseDTO = userService.verifyEmail(token);
JsonResponse response = new JsonResponse(HttpStatus.OK.value(), "이메일 인증이 완료되었습니다.", userResponseDTO);
return ResponseEntity.ok(response);
}
verifyEmail()
token
: 이메일 인증 토큰 (URL Path 변수로 전달됨)UserController
에 넣은 이유?관련 기능의 응집성:
이메일 인증은 회원가입, 회원 정보 수정 등 사용자 CRUD와 밀접하게 연결된 기능이다. 특히 회원 가입 후 계정을 활성화하기 위한 중요한 단계로서, 하나의 컨트롤러에 모아서 관리하는 편이 응집성을 높인다.
기능의 일관성:
UserController
에서 사용자의 전반적인 관리를 담당하므로, 이메일 인증과 같은 중요한 기능도 같은 컨트롤러에 포함시키는 것이 일관성을 유지하는 데 도움이 되고, 이렇게 하면 사용자와 관련된 모든 요청을 한 곳에서 처리할 수 있기 때문에 코드 구조가 더욱 명확해진다.
유지보수의 편리함:
관련된 기능이 하나의 컨트롤러에 모여 있으면, 해당 기능을 유지보수하거나 확장할 때 더 쉽게 관리할 수 있다. 또한 변경사항이 발생했을 때 그 영향을 쉽게 파악하고 수정할 수 있다.
이러한 이유로 나는 이메일 인증 API를 UserController
에 넣었다.
만약 앞으로 확장할 가능성이 큰 대형 프로젝트라면 세세한 것까지 분리하는 편이 좋겠지만, 아무래도 이건 개인적으로 진행하는 미니 프로젝트인 만큼 간단한 구조가 더욱 편리할 것이라고 생각한다.
Test
지금까지의 코드를 잘 작성했다면 정상적으로 이메일 인증 처리가 될 것이다.
POSTMAN
등을 통해 회원가입 API를 호출하면
이렇게 DB에 비활성화된 채로 저장되고
회원가입 시 적었던 이메일 주소로 정상적으로 발송된다.
링크를 클릭하면 이렇게 정상적으로 이메일 인증이 완료되고
DB에도 다시 enabled
이 활성화된 것을 알 수 있다.
if
비활성화된 채로 로그인을 시도하면?만약 이메일 인증을 완료하기 전 로그인을 시도하면 사진과 같이 올바르지 않은 로그인 시도로 판별되어 401
에러를 반환한다.
후에 이메일 인증과 관련된 메시지를 반환하도록 예외 처리를 추가해도 좋을 것 같다.
이번에는 이메일 인증 기능을 추가해 보았다. 간단해 보이지만 은근히 Security
설정이나 SMTP
보안 등 신경쓸 점이 몇 가지 있어서 몇 시간이 걸린 것 같다.
다음에는 드디어 OAuth
로그인에 대해 다루게 될 것 같다. 예전의 나에게는 에러가 나고 복잡했던 내용인 만큼 잘 다루어 보도록 하겠다.