diff --git a/.github/workflows/build-all.yml b/.github/workflows/build-all.yml new file mode 100644 index 0000000..432f22d --- /dev/null +++ b/.github/workflows/build-all.yml @@ -0,0 +1,50 @@ +name: Create and publish a Docker image for Each branches + +on: + push: +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + environment: + name: deploy + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: "Checkout repository" + uses: actions/checkout@v4 + + - name: 'Set up Docker Buildx' + uses: docker/setup-buildx-action@v3 + + - name: 'Run gradlew build' + run: ./gradlew build + + - name: "Log in to the Container registry" + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: "Extract metadata (tags, labels) for Docker" + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: "Build and push Docker image" + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + file: Dockerfile diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index e9bdade..c9f82d2 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -26,6 +26,11 @@ jobs: - name: 'Set up Docker Buildx' uses: docker/setup-buildx-action@v3 + - name: 'Replace application.yml for docker deployment' + run: | + rm ./src/main/resources/application.yml + mv ./src/main/resources/application.docker.yml ./src/main/resources/application.yml + - name: 'Run gradlew build' run: ./gradlew build diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 56f8c39..1f2961f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,11 @@ jobs: - name: 'Set up Docker Buildx' uses: docker/setup-buildx-action@v3 + - name: 'Replace application.yml for docker deployment' + run: | + rm ./src/main/resources/application.yml + mv ./src/main/resources/application.docker.yml ./src/main/resources/application.yml + - name: 'Run gradlew build' run: ./gradlew build diff --git a/build.gradle b/build.gradle index 9f327e2..9229325 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.security:spring-security-core' testImplementation 'org.springframework.boot:spring-boot-starter-test' runtimeOnly 'com.h2database:h2' diff --git a/docker-compose.monolithic.yml b/docker-compose.monolithic.yml index 9fb81ba..0ba2e7d 100644 --- a/docker-compose.monolithic.yml +++ b/docker-compose.monolithic.yml @@ -1,13 +1,13 @@ services: db: - image: mariadb:latest + image: mysql:latest container_name: 'unideal-db' environment: # MYSQL_ROOT_PASSWORD: changeme MYSQL_DATABASE: unideal MYSQL_USER: unideal MYSQL_PASSWORD: changeme - MARIADB_RANDOM_ROOT_PASSWORD: 1 + MYSQL_RANDOM_ROOT_PASSWORD: 1 volumes: - unideal-db_data:/var/lib/mysql app: diff --git a/src/main/java/kr/unideal/server/backend/controller/AuthController.java b/src/main/java/kr/unideal/server/backend/controller/AuthController.java new file mode 100644 index 0000000..72e5932 --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/controller/AuthController.java @@ -0,0 +1,74 @@ +package kr.unideal.server.backend.controller; + +import kr.unideal.server.backend.dto.*; +import kr.unideal.server.backend.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class AuthController { + + private final UserService userService; + + @PostMapping("/auth/signup") + public ResponseEntity signup( + @RequestBody SignUpRequestDTO signUpRequestDTO, + BindingResult bindingResult + ) { + + if (bindingResult.hasErrors()) { + throw new IllegalArgumentException("입력값 규격이 올바르지 않습니다."); + } + + try { + userService.register(signUpRequestDTO); + } catch (IllegalArgumentException e) { + throw e; + } + + return ResponseEntity.ok( + new SignUpResponseDTO() + ); + } + + // 인증번호 기반 코드 인증 시도 + @PostMapping("/auth/validate") + public ResponseEntity validate(@RequestBody VerifyRequestDTO verifyRequestDTO, BindingResult bindingResult) { + if (bindingResult.hasErrors()) { + throw new IllegalArgumentException("입력값 규격이 올바르지 않습니다."); + } + + try { + userService.verifyUser(verifyRequestDTO); + } catch (IllegalArgumentException e) { + throw e; + } + + return ResponseEntity.ok( + new VerifyResponseDTO() + ); + } + + @PostMapping("/auth/login") + public ResponseEntity login( + @RequestBody LogInRequestDTO logInRequestDTO, + BindingResult bindingResult + ) { + if (bindingResult.hasErrors()) { + throw new IllegalArgumentException("입력값 규격이 올바르지 않습니다."); + } + + try { + userService.login(logInRequestDTO); + } catch (IllegalArgumentException e) { + throw e; + } + + return ResponseEntity.ok( + new LogInResponseDTO() + ); + } +} \ No newline at end of file diff --git a/src/main/java/kr/unideal/server/backend/controller/AuthPageController.java b/src/main/java/kr/unideal/server/backend/controller/AuthPageController.java new file mode 100644 index 0000000..da6729b --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/controller/AuthPageController.java @@ -0,0 +1,33 @@ +package kr.unideal.server.backend.controller; + +import kr.unideal.server.backend.dto.LogInRequestDTO; +import kr.unideal.server.backend.dto.SignUpRequestDTO; +import kr.unideal.server.backend.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +// 대체 왜 여기에 SSR 을???? +// 일단 API는 RestController 사용하는 것이 편하므로 분리 +// +// 아니 근데 FE React 로 만들기로 한거 아녔나.... Thymeleaf 로 왜...? +@Controller +@RequiredArgsConstructor +public class AuthPageController { + + //signup 페이지 GET + @GetMapping("/signup") + public String signupPage(Model model) { + model.addAttribute("signUpRequestDTO", new SignUpRequestDTO()); + return "signup"; + } + + //login 페이지 Get + @GetMapping("/login") + public String loginPage(Model model) { + model.addAttribute("logInRequestDTO", new LogInRequestDTO()); + return "login"; + } + +} \ No newline at end of file diff --git a/src/main/java/kr/unideal/server/backend/dto/LogInRequestDTO.java b/src/main/java/kr/unideal/server/backend/dto/LogInRequestDTO.java new file mode 100644 index 0000000..315f57c --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/dto/LogInRequestDTO.java @@ -0,0 +1,17 @@ +package kr.unideal.server.backend.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LogInRequestDTO { + @NotBlank + @Email + private String email; + + @NotBlank + private String password; +} \ No newline at end of file diff --git a/src/main/java/kr/unideal/server/backend/dto/LogInResponseDTO.java b/src/main/java/kr/unideal/server/backend/dto/LogInResponseDTO.java new file mode 100644 index 0000000..d721e1c --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/dto/LogInResponseDTO.java @@ -0,0 +1,4 @@ +package kr.unideal.server.backend.dto; + +public class LogInResponseDTO { +} diff --git a/src/main/java/kr/unideal/server/backend/dto/SignUpRequestDTO.java b/src/main/java/kr/unideal/server/backend/dto/SignUpRequestDTO.java new file mode 100644 index 0000000..53b21d1 --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/dto/SignUpRequestDTO.java @@ -0,0 +1,23 @@ +package kr.unideal.server.backend.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class SignUpRequestDTO { + @Getter + @Setter + + @NotBlank + @Email + private String email; + + @NotBlank + private String password; + + @NotBlank + private String name; +} \ No newline at end of file diff --git a/src/main/java/kr/unideal/server/backend/dto/SignUpResponseDTO.java b/src/main/java/kr/unideal/server/backend/dto/SignUpResponseDTO.java new file mode 100644 index 0000000..489dda3 --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/dto/SignUpResponseDTO.java @@ -0,0 +1,5 @@ +package kr.unideal.server.backend.dto; + +public class SignUpResponseDTO { + +} diff --git a/src/main/java/kr/unideal/server/backend/dto/VerifyRequestDTO.java b/src/main/java/kr/unideal/server/backend/dto/VerifyRequestDTO.java new file mode 100644 index 0000000..0dc9681 --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/dto/VerifyRequestDTO.java @@ -0,0 +1,20 @@ +package kr.unideal.server.backend.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +public class VerifyRequestDTO { + @Getter + @Setter + + @NotBlank + @Email + String email; + + @Getter + @Setter + @NotBlank + String code; +} diff --git a/src/main/java/kr/unideal/server/backend/dto/VerifyResponseDTO.java b/src/main/java/kr/unideal/server/backend/dto/VerifyResponseDTO.java new file mode 100644 index 0000000..1362018 --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/dto/VerifyResponseDTO.java @@ -0,0 +1,4 @@ +package kr.unideal.server.backend.dto; + +public class VerifyResponseDTO { +} diff --git a/src/main/java/kr/unideal/server/backend/entity/User.java b/src/main/java/kr/unideal/server/backend/entity/User.java new file mode 100644 index 0000000..baf0424 --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/entity/User.java @@ -0,0 +1,35 @@ +package kr.unideal.server.backend.entity; + +import jakarta.persistence.*; +import kr.unideal.server.backend.utils.VerificationCodeUtils; +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Instant; + +@Entity +@Table(name = "`user`") // user는 예약어이므로 백틱 사용 +@Getter +@Setter +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 100) + private String email; + + @Column(nullable = false, length = 255) + private String password; + + @Column(nullable = false, length = 100) + private String name; + + @Column(nullable = false) + private boolean isVerified = false; + + @Column + private String verificationToken; +} \ No newline at end of file diff --git a/src/main/java/kr/unideal/server/backend/repository/UserRepository.java b/src/main/java/kr/unideal/server/backend/repository/UserRepository.java new file mode 100644 index 0000000..d8ce301 --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/repository/UserRepository.java @@ -0,0 +1,14 @@ +package kr.unideal.server.backend.repository; + +import kr.unideal.server.backend.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + //존재하는 이메일인지 확인 + boolean existsByEmail(String email); + + //user의 데이터를 email로 찾음 + Optional findByEmail(String email); +} \ No newline at end of file diff --git a/src/main/java/kr/unideal/server/backend/service/MailService.java b/src/main/java/kr/unideal/server/backend/service/MailService.java new file mode 100644 index 0000000..85f2b86 --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/service/MailService.java @@ -0,0 +1,35 @@ +package kr.unideal.server.backend.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +public class MailService { + + @Value("${unideal.mailer.from}") + private String from; + + @Autowired + private JavaMailSender mailSender; + + public void sendSimpleMail(String to, String subject, String text) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(from); + message.setTo(to); + message.setSubject(subject); + message.setText(text); + + mailSender.send(message); + } + + public void sendVerificationCode(String to, String code) { + this.sendSimpleMail( + to, + "Unideal 가입 인증코드", + "Unideal에 가입해 주셔서 감사합니다. 회원가입을 위한 인증코드는 "+code+" 입니다." + ); + } +} \ No newline at end of file diff --git a/src/main/java/kr/unideal/server/backend/service/UserService.java b/src/main/java/kr/unideal/server/backend/service/UserService.java new file mode 100644 index 0000000..ff3696c --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/service/UserService.java @@ -0,0 +1,103 @@ +package kr.unideal.server.backend.service; + + +import kr.unideal.server.backend.dto.LogInRequestDTO; +import kr.unideal.server.backend.dto.SignUpRequestDTO; +import kr.unideal.server.backend.dto.VerifyRequestDTO; +import kr.unideal.server.backend.entity.User; +import kr.unideal.server.backend.repository.UserRepository; +import kr.unideal.server.backend.utils.VerificationCodeUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.Instant; + + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final MailService mailService; + private final ValidatorService validatorService; + + private PasswordEncoder passwordEncoder; + + //회원가입 db 등록 method + public void register(SignUpRequestDTO dto) { + if (userRepository.existsByEmail(dto.getEmail())) { + throw new IllegalArgumentException("이미 등록된 이메일입니다."); + } + + if (!validatorService.isGachonUnivStudent(dto.getEmail())) { + throw new IllegalArgumentException("가천대학교 학생 이메일이 아닙니다."); + } + + User user = new User(); + user.setEmail(dto.getEmail()); + user.setName(dto.getName()); + + // HASH FIRST - PlainText password in database is security nightmare + user.setPassword(passwordEncoder.encode(dto.getPassword())); + + //이메일 인증이 완료 되어야 회원가입을 진행할 예정이라 일단 verified = TRUE 로 해둠 + this.issueVerificationCode(user); + user.setVerified(false); + + userRepository.save(user); + }; + + //로그인 정보 확인 method + public User login(LogInRequestDTO dto) { + User user = userRepository.findByEmail(dto.getEmail()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 이메일입니다.")); + + if (!user.isVerified()) { + throw new IllegalArgumentException("아직 이메일이 인증되지 않았습니다."); + } + + // bcrypt implementation + String plainText = dto.getPassword(); + String bcrypt = user.getPassword(); + + if (!passwordEncoder.matches(plainText, bcrypt)) { + throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + } + + return user; + }; + + // 인증 + public boolean verifyUser(VerifyRequestDTO dto) { + User user = userRepository.findByEmail(dto.getEmail()) + .orElseThrow(() -> new IllegalArgumentException("이메일이 올바르지 않습니다.")); + + if (user.isVerified()) throw new IllegalArgumentException("이미 인증된 사용자입니다."); + + boolean result = this.verifyUser(user, dto.getCode()); + if (result) { + user.setVerified(true); + } + + return result; + } + + private boolean verifyUser(User user, String verificationCode) { + if (user.isVerified()) return true; + + String verificationToken = user.getVerificationToken(); + if (verificationToken == null) { + return false; + } else return verificationToken.equals(verificationCode); + } + + // 인증토큰 발급 + public void issueVerificationCode(User user) { + String verificationCode = VerificationCodeUtils.generateVerificationCode(); + + user.setVerificationToken(verificationCode); + mailService.sendVerificationCode(user.getEmail(), verificationCode); + } +} diff --git a/src/main/java/kr/unideal/server/backend/service/ValidatorService.java b/src/main/java/kr/unideal/server/backend/service/ValidatorService.java new file mode 100644 index 0000000..c8925e4 --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/service/ValidatorService.java @@ -0,0 +1,21 @@ +package kr.unideal.server.backend.service; + +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; + +@Service +public class ValidatorService { + + public boolean isGachonUnivStudent(String email) { + String[] splitted = email.toLowerCase().split("@"); + String hostname = splitted[splitted.length - 1]; + + if (hostname.endsWith("gachon.ac.kr")) { + // Gachon University hostname + return true; + } + + return false; + } +} diff --git a/src/main/java/kr/unideal/server/backend/utils/VerificationCodeUtils.java b/src/main/java/kr/unideal/server/backend/utils/VerificationCodeUtils.java new file mode 100644 index 0000000..7e393d5 --- /dev/null +++ b/src/main/java/kr/unideal/server/backend/utils/VerificationCodeUtils.java @@ -0,0 +1,23 @@ +package kr.unideal.server.backend.utils; + +import java.security.SecureRandom; + +public class VerificationCodeUtils { + static String verificationCodeCharacters = "0123456789"; + static int verificationCodeLength = 6; + + public static String generateVerificationCode() { + // use system-wide entropy to make sure that + // verification code can not be predicted via + // PRNG seed prediction + SecureRandom random = new SecureRandom(); + StringBuilder builder = new StringBuilder(); + + for (int i = 0; i < verificationCodeLength; i++) { + int idx = random.nextInt(verificationCodeCharacters.length()); + builder.append(verificationCodeCharacters.charAt(idx)); + } + + return builder.toString(); + } +} diff --git a/src/main/resources/application.docker.yml b/src/main/resources/application.docker.yml new file mode 100644 index 0000000..0f91af1 --- /dev/null +++ b/src/main/resources/application.docker.yml @@ -0,0 +1,21 @@ +spring: + application: + name: unideal + jpa: + hibernate: + ddl-auto: update + # Verbose logging for SQL + show-sql: true + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + # NOTE: Do NOT set the db configuration here, + # This would be set by the docker-compose via environment variable. + # + # For more information, Check the following link: + # https://docs.spring.io/spring-boot/docs/3.0.4/reference/html/features.html#features.external-config + # If you configure in this file, the environment variables are automatically overridden, + # causing invalid configuration. If you need to configure some of the parameters, reach me @ Kakaotalk. + +springdoc: + api-docs: + path: /openapi diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c13b271..e673536 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,7 +6,30 @@ spring: ddl-auto: update # Verbose logging for SQL show-sql: true + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + # == this is only for local deployments == + # For more information, check ./application.docker.yml + url: jdbc:mysql://localhost:3306/UniDeal + username: root + password: 'tndk1008' + mail: + host: smtp.google.com + port: 587 + username: username@example.com + password: password12 + properties: + mail: + smtp: + auth: true + starttls: + enable: true springdoc: api-docs: path: /openapi + +unideal: + mailer: + from: test-mail@gmail.com + diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 0000000..37e4c70 --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,22 @@ + + + + + 로그인 + + +

로그인

+ +
+
+ + +
+ + +
+ + +
+ + \ No newline at end of file diff --git a/src/main/resources/templates/signup.html b/src/main/resources/templates/signup.html new file mode 100644 index 0000000..c22a0d8 --- /dev/null +++ b/src/main/resources/templates/signup.html @@ -0,0 +1,21 @@ + + + + 회원가입 + + +

회원가입

+
+ +
+ + +
+ + +
+ + +
+ + \ No newline at end of file diff --git a/src/test/java/kr/unideal/server/backend/UnidealBackendApplicationTests.java b/src/test/java/kr/unideal/server/backend/UnidealBackendApplicationTests.java deleted file mode 100644 index 31c8f3a..0000000 --- a/src/test/java/kr/unideal/server/backend/UnidealBackendApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package kr.unideal.server.backend; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class UnidealBackendApplicationTests { - - @Test - void contextLoads() { - } - -}