Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[회원] 회원가입 기능, 이메일 인증 기능, 토큰 발행 기능 구현 #42

Merged
merged 12 commits into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,18 @@ repositories {

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.retry:spring-retry'

implementation 'io.jsonwebtoken:jjwt-api:0.12.1'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.1'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.1'

compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'com.h2database:h2'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.chang.omg.domain.auth.exception;

import com.chang.omg.global.exception.BusinessException;
import com.chang.omg.global.exception.ExceptionCode;

import lombok.Getter;

@Getter
public class AuthException extends BusinessException {

public AuthException(final ExceptionCode exceptionCode, final Object... rejectedValues) {
super(exceptionCode, rejectedValues);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.chang.omg.domain.auth.exception;

import org.springframework.http.HttpStatus;

import com.chang.omg.global.exception.ExceptionCode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum AuthExceptionCode implements ExceptionCode {

AUTH_EXPIRED_REGISTER_TOKEN(HttpStatus.BAD_REQUEST, "AUT-001", "RegisterToken 토큰 만료"),
AUTH_INVALID_TOKEN(HttpStatus.BAD_REQUEST, "AUT-002", "유효하지 않은 Token"),
AUTH_FAIL_TO_VALIDATE_TOKEN(HttpStatus.BAD_REQUEST, "AUT-003", "토큰을 검증할 수 없음");

private final HttpStatus status;
private final String code;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.chang.omg.domain.auth.security;

import org.springframework.stereotype.Component;

import com.chang.omg.domain.auth.exception.AuthException;
import com.chang.omg.domain.auth.exception.AuthExceptionCode;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class JwtTokenExtractor {

private static final String BEARER_TYPE = "Bearer ";

public String extractToken(final String header) {
if (header != null && header.startsWith(BEARER_TYPE)) {
return header.substring(BEARER_TYPE.length()).trim();
}

throw new AuthException(AuthExceptionCode.AUTH_INVALID_TOKEN, header);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.chang.omg.domain.auth.security;

import java.util.Date;
import java.util.Map;

import javax.crypto.SecretKey;

import org.springframework.stereotype.Component;

import com.chang.omg.global.config.property.AuthProperties;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

private final AuthProperties authProperties;
private SecretKey secretKey;

@PostConstruct
public void initialize() {
final byte[] keyBytes = Decoders.BASE64.decode(authProperties.getSecretKey());
secretKey = Keys.hmacShaKeyFor(keyBytes);
}

public String createRegisterToken(final String email) {
final Map<String, String> claims = Map.of("email", email);

return generateToken(TokenType.REGISTER.toString(), claims, authProperties.getRegisterTokenExpirationTime());
}

private String generateToken(final String subject, final Map<String, String> claims, final Long expirationTime) {
final Date now = new Date();

return Jwts.builder()
.subject(subject)
.claims(claims)
.issuedAt(now)
.expiration(new Date(now.getTime() + expirationTime))
.signWith(secretKey)
.compact();
}

public String getSubject(final String token) {
return parseToken(token)
.getPayload()
.getSubject();
}

public String getClaim(final String token, final String claim) {
return (String)parseToken(token)
.getPayload()
.get(claim);
}

private Jws<Claims> parseToken(final String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.chang.omg.domain.auth.security;

public enum TokenType {
REGISTER, LOGIN
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import java.util.Objects;

import com.chang.omg.common.domain.BaseEntity;
import com.chang.omg.global.converter.GameTypeAttributeConverter;
import com.chang.omg.global.domain.BaseEntity;

import jakarta.persistence.Column;
import jakarta.persistence.Convert;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.chang.omg.domain.mail.service;

import java.text.MessageFormat;
import java.util.function.Function;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum MailMessageTemplate {
AUTH_CODE(email -> MessageFormat.format("회원가입 인증번호는 {0}입니다.", email)),
;

private final Function<String, String> messageFormatter;

public String getMessage(final String value) {
return messageFormatter.apply(value);
}
}
26 changes: 26 additions & 0 deletions src/main/java/com/chang/omg/domain/mail/service/MailService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.chang.omg.domain.mail.service;

import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

import com.chang.omg.domain.mail.service.dto.EmailDetails;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class MailService {

private final JavaMailSender mailSender;

public void sendEmail(final EmailDetails emailDetails) {
final SimpleMailMessage message = new SimpleMailMessage();

message.setTo(emailDetails.receiver());
message.setSubject(emailDetails.subject());
message.setText(emailDetails.message());

mailSender.send(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.chang.omg.domain.mail.service;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum MailSubjectTemplate {
AUTH_CODE("[OhMobileGame] 회원가입 인증번호 입니다."),
;

private final String subject;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.chang.omg.domain.mail.service.dto;

import lombok.Builder;

@Builder
public record EmailDetails(String subject, String message, String receiver) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.chang.omg.domain.member.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;

import com.chang.omg.domain.auth.security.JwtTokenExtractor;
import com.chang.omg.domain.auth.security.JwtTokenProvider;
import com.chang.omg.domain.auth.security.TokenType;
import com.chang.omg.domain.member.controller.dto.request.MemberAuthCodeRequest;
import com.chang.omg.domain.member.controller.dto.request.MemberCreateRequest;
import com.chang.omg.domain.member.controller.dto.request.MemberVerificationRequest;
import com.chang.omg.domain.member.controller.dto.response.MemberCreateResponse;
import com.chang.omg.domain.member.controller.dto.response.MemberRegisterTokenResponse;
import com.chang.omg.domain.member.exception.MemberException;
import com.chang.omg.domain.member.exception.MemberExceptionCode;
import com.chang.omg.domain.member.service.MemberService;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {

private final JwtTokenProvider jwtTokenProvider;
private final JwtTokenExtractor jwtTokenExtractor;
private final MemberService memberService;

@PostMapping
public ResponseEntity<MemberCreateResponse> createMember(
@RequestBody @Valid final MemberCreateRequest memberCreateRequest,
@RequestHeader final String authorization
) {
final String registerToken = jwtTokenExtractor.extractToken(authorization);
final String tokenType = jwtTokenProvider.getSubject(registerToken);
final String tokenEmail = jwtTokenProvider.getClaim(registerToken, "email");

if (!tokenType.equals(TokenType.REGISTER.toString()) || !tokenEmail.equals(memberCreateRequest.email())) {
throw new MemberException(MemberExceptionCode.MEMBER_NOT_MATCHED_TOKEN_DETAILS);
}

final Long memberId = memberService.createMember(memberCreateRequest);

return ResponseEntity.ok(new MemberCreateResponse(memberId));
}

@PostMapping("/verification-auth-code")
public ResponseEntity<Void> createAuthCodeAndSendEmail(
@RequestBody final MemberAuthCodeRequest memberAuthCodeRequest
) {
memberService.createAuthCodeAndSendEmail(memberAuthCodeRequest);

return ResponseEntity.noContent()
.build();
}

@GetMapping("/verification-auth-code")
public ResponseEntity<MemberRegisterTokenResponse> verifyAuthCode(
@ModelAttribute final MemberVerificationRequest memberVerificationRequest
) {
final String registerToken = memberService.verifyAuthCode(memberVerificationRequest);

return ResponseEntity.ok(new MemberRegisterTokenResponse(registerToken));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.chang.omg.domain.member.controller.dto.request;

public record MemberAuthCodeRequest(String email) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.chang.omg.domain.member.controller.dto.request;

import java.time.LocalDate;

import org.hibernate.validator.constraints.Length;

import com.chang.omg.domain.member.domain.Member;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;

public record MemberCreateRequest(
@Pattern(regexp = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$", message = "이메일 양식 확인 필요") String email,
@Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=!])(?=\\S+$).{8,}$", message = "패스워드는 8자 이상이어야 하며, 숫자, 대문자, 소문자, 특수문자를 포함해야 합니다") String password,
@NotBlank(message = "닉네임이 없거나 공백인 경우 확인 필요") @Length(min = 3, max = 20, message = "닉네임은 3글자 이상 20글자 이하") String nickname,
@NotNull(message = "날짜가 없는 경우 확인 필요") LocalDate birthDate
) {

public Member toEntity(final String encryptedPassword) {
return Member.builder()
.email(email)
.password(encryptedPassword)
.nickname(nickname)
.birthDate(birthDate)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.chang.omg.domain.member.controller.dto.request;

public record MemberVerificationRequest(String email, int authCode) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.chang.omg.domain.member.controller.dto.response;

public record MemberCreateResponse(Long memberId) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.chang.omg.domain.member.controller.dto.response;

public record MemberRegisterTokenResponse(String registerToken) {

}
Loading
Loading