diff --git a/src/main/java/com/arom/with_travel/domain/member/Member.java b/src/main/java/com/arom/with_travel/domain/member/Member.java index 1542018..854ece3 100644 --- a/src/main/java/com/arom/with_travel/domain/member/Member.java +++ b/src/main/java/com/arom/with_travel/domain/member/Member.java @@ -9,6 +9,7 @@ import com.arom.with_travel.domain.community_reply.CommunityReplyLike; import com.arom.with_travel.domain.image.Image; import com.arom.with_travel.domain.likes.Likes; +import com.arom.with_travel.domain.member.dto.request.MemberSignupRequestDto; import com.arom.with_travel.domain.shorts.Shorts; import com.arom.with_travel.domain.shorts_reply.ShortsReply; import com.arom.with_travel.domain.survey.Survey; @@ -29,6 +30,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLDelete(sql = "UPDATE member SET is_deleted = true, deleted_at = now() where id = ?") @SQLRestriction("is_deleted is FALSE") +@Builder +@AllArgsConstructor public class Member extends BaseEntity { @Id @@ -70,7 +73,8 @@ public enum Gender { } public enum LoginType { - KAKAO + KAKAO, + LOCAL } public Member(String memberName, String email, Role role) { @@ -129,25 +133,6 @@ public static Member create(String memberName, String email, String oauthId) { @OneToOne(mappedBy = "member") private Image image; - @Builder - public Member(Long id, String oauthId, String email, String password, String name, - LocalDate birth, Gender gender, String phone, LoginType loginType, - String nickname, String introduction, TravelType travelType, Role role) { - this.id = id; - this.oauthId = oauthId; - this.email = email; - this.password = password; - this.name = name; - this.birth = birth; - this.gender = gender; - this.phone = phone; - this.loginType = loginType; - this.nickname = nickname; - this.introduction = introduction; - this.travelType = travelType; - this.role = role; - } - public void validateNotAlreadyAppliedTo(Accompany accompany) { boolean alreadyApplied = accompanyApplies.stream() .anyMatch(apply -> apply.getAccompany().equals(accompany)); @@ -166,12 +151,17 @@ public static Member signUp(String email, String oauthId) { .build(); } - // 신규 회원 추가 정보 등록; 닉네임/생년월일/성별 - public void updateExtraInfo(String nickname, LocalDate birth, Gender gender, String introduction) { + // 신규 회원 추가 정보 등록; + public void updateExtraInfo(String nickname, LocalDate birth, Gender gender, String introduction, + String email, String password, String name, String phone) { this.nickname = nickname; this.birth = birth; this.gender = gender; this.introduction = introduction; + this.email = email; + this.password = password; + this.name = name; + this.phone = phone; } public void markAdditionalDataChecked() { diff --git a/src/main/java/com/arom/with_travel/domain/member/controller/AuthController.java b/src/main/java/com/arom/with_travel/domain/member/controller/AuthController.java new file mode 100644 index 0000000..751b25a --- /dev/null +++ b/src/main/java/com/arom/with_travel/domain/member/controller/AuthController.java @@ -0,0 +1,36 @@ +package com.arom.with_travel.domain.member.controller; + +import com.arom.with_travel.domain.member.dto.request.LocalLoginRequest; +import com.arom.with_travel.domain.member.dto.request.SignupWithSurveyRequestDto; +import com.arom.with_travel.domain.member.dto.response.LoginResponse; +import com.arom.with_travel.domain.member.service.LocalAuthService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final LocalAuthService authService; + + // 이메일 중복 체크 + @GetMapping("/email-available") + public boolean emailAvailable(@RequestParam String email) { + return authService.isEmailAvailable(email); + } + + // 이메일 등록(회원가입) + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody SignupWithSurveyRequestDto req) { + return ResponseEntity.ok(authService.registerWithSurvey(req)); + } + + // 로그인 + @PostMapping("/login") + public LoginResponse login(@Valid @RequestBody LocalLoginRequest req) { + return authService.login(req); + } +} diff --git a/src/main/java/com/arom/with_travel/domain/member/dto/request/LocalLoginRequest.java b/src/main/java/com/arom/with_travel/domain/member/dto/request/LocalLoginRequest.java new file mode 100644 index 0000000..fab5314 --- /dev/null +++ b/src/main/java/com/arom/with_travel/domain/member/dto/request/LocalLoginRequest.java @@ -0,0 +1,17 @@ +package com.arom.with_travel.domain.member.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LocalLoginRequest { + @NotBlank @Email + private String email; + + @NotBlank @Size(min=8, max=64) + private String password; +} diff --git a/src/main/java/com/arom/with_travel/domain/member/dto/request/MemberSignupRequestDto.java b/src/main/java/com/arom/with_travel/domain/member/dto/request/MemberSignupRequestDto.java index b7598c0..ffbb6bf 100644 --- a/src/main/java/com/arom/with_travel/domain/member/dto/request/MemberSignupRequestDto.java +++ b/src/main/java/com/arom/with_travel/domain/member/dto/request/MemberSignupRequestDto.java @@ -46,7 +46,10 @@ public class MemberSignupRequestDto { private String name; @NotBlank(message = "전화번호를 입력해주세요.") - @Pattern(regexp = "^[0-9\\-]{8,15}$", message = "전화번호 형식이 올바르지 않습니다.") + @Pattern( + regexp = "^01[0-9]-\\d{3,4}-\\d{4}$", + message = "전화번호 형식이 올바르지 않습니다. (예: 010-1234-5678)" + ) @Schema(description = "전화번호", example = "010-1234-5678") private String phone; } diff --git a/src/main/java/com/arom/with_travel/domain/member/dto/response/LoginResponse.java b/src/main/java/com/arom/with_travel/domain/member/dto/response/LoginResponse.java new file mode 100644 index 0000000..61c8c92 --- /dev/null +++ b/src/main/java/com/arom/with_travel/domain/member/dto/response/LoginResponse.java @@ -0,0 +1,12 @@ +package com.arom.with_travel.domain.member.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class LoginResponse { + private String accessToken; + private String refreshToken; + private boolean infoChecked; +} diff --git a/src/main/java/com/arom/with_travel/domain/member/service/LocalAuthService.java b/src/main/java/com/arom/with_travel/domain/member/service/LocalAuthService.java new file mode 100644 index 0000000..da64318 --- /dev/null +++ b/src/main/java/com/arom/with_travel/domain/member/service/LocalAuthService.java @@ -0,0 +1,100 @@ +package com.arom.with_travel.domain.member.service; + +import com.arom.with_travel.domain.member.Member; +import com.arom.with_travel.domain.member.dto.request.LocalLoginRequest; +import com.arom.with_travel.domain.member.dto.request.MemberSignupRequestDto; +import com.arom.with_travel.domain.member.dto.request.SignupWithSurveyRequestDto; +import com.arom.with_travel.domain.member.dto.response.LoginResponse; +import com.arom.with_travel.domain.member.repository.MemberRepository; +import com.arom.with_travel.domain.survey.Survey; +import com.arom.with_travel.domain.survey.dto.request.SurveyRequestDto; +import com.arom.with_travel.domain.survey.repository.SurveyRepository; +import com.arom.with_travel.global.exception.BaseException; +import com.arom.with_travel.global.exception.error.ErrorCode; +import com.arom.with_travel.global.jwt.dto.response.AuthTokenResponse; +import com.arom.with_travel.global.security.token.provider.JwtProvider; +import com.arom.with_travel.global.security.token.service.TokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class LocalAuthService { + + private final MemberRepository memberRepository; + private final SurveyRepository surveyRepository; + private final TokenService tokenService; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; + + @Transactional(readOnly = true) + public boolean isEmailAvailable(String email) { + boolean duplicated = memberRepository.existsByEmail(email); + return !duplicated; + } + + // 신규 회원 추가 정보 + 설문 통합 등록 + public LoginResponse registerWithSurvey(SignupWithSurveyRequestDto req) { + + String email = req.getExtraInfo().getEmail(); + if(!isEmailAvailable(email)) { + throw BaseException.from(ErrorCode.DUPLICATED_EMAIL); + } + + MemberSignupRequestDto extra = req.getExtraInfo(); + String encodedPassword = passwordEncoder.encode(extra.getPassword()); + + Member member = Member.builder() + .email(extra.getEmail()) + .password(encodedPassword) + .name(extra.getName()) + .phone(extra.getPhone()) + .birth(extra.getBirthdate()) + .gender(extra.getGender()) + .nickname(extra.getNickname()) + .introduction(extra.getIntroduction()) + .role(Member.Role.USER) + .additionalDataChecked(false) + .build(); + + member = memberRepository.save(member); + + SurveyRequestDto s = req.getSurvey(); + Survey survey = Survey.create(member, s); + surveyRepository.save(survey); + member.setSurvey(survey); + + member.markAdditionalDataChecked(); + + AuthTokenResponse tokenPair = tokenService.issueTokenPair(member.getEmail()); + + return new LoginResponse( + tokenPair.getAccessToken(), + tokenPair.getRefreshToken(), + member.getAdditionalDataChecked() + ); + } + + private Member getUserByLoginEmailOrElseThrow(String loginEmail) { + return memberRepository.findByEmail(loginEmail) + .orElseThrow(() -> BaseException.from(ErrorCode.MEMBER_NOT_FOUND)); + } + + // 로그인: raw, hashed 비번 비교 → 토큰 발급 + @Transactional(readOnly = true) + public LoginResponse login(LocalLoginRequest req) { + Member m = memberRepository.findByEmail(req.getEmail()) + .orElseThrow(() -> BaseException.from(ErrorCode.LOGIN_FAIL)); + + if (m.getPassword() == null || !passwordEncoder.matches(req.getPassword(), m.getPassword())) { + throw BaseException.from(ErrorCode.LOGIN_FAIL); + } + + String access = jwtProvider.generateAccessToken(m); + String refresh = jwtProvider.generateRefreshToken(m); + return new LoginResponse(access, refresh, Boolean.TRUE.equals(m.getAdditionalDataChecked())); + } +} \ No newline at end of file diff --git a/src/main/java/com/arom/with_travel/domain/member/service/MemberSignupService.java b/src/main/java/com/arom/with_travel/domain/member/service/MemberSignupService.java index 56955c4..fca6494 100644 --- a/src/main/java/com/arom/with_travel/domain/member/service/MemberSignupService.java +++ b/src/main/java/com/arom/with_travel/domain/member/service/MemberSignupService.java @@ -53,7 +53,9 @@ public MemberSignupResponseDto registerWithSurvey(String email, Member member = getUserByLoginEmailOrElseThrow(email); MemberSignupRequestDto extra = req.getExtraInfo(); - member.updateExtraInfo(extra.getNickname(), extra.getBirthdate(), extra.getGender(), extra.getIntroduction()); + member.updateExtraInfo(extra.getNickname(), extra.getBirthdate(), extra.getGender(), + extra.getIntroduction(), extra.getEmail(), extra.getPassword(), + extra.getName(), extra.getPhone()); // req.getSurveys().forEach(sdto -> { // Survey survey = Survey.create(member, sdto.getAnswers()); @@ -84,7 +86,7 @@ public Member createIfNotExists(CustomOAuth2User user) { return memberRepository.save(inserted); }); } - + private Member getUserByLoginEmailOrElseThrow(String loginEmail) { return memberRepository.findByEmail(loginEmail) .orElseThrow(() -> BaseException.from(ErrorCode.MEMBER_NOT_FOUND)); @@ -97,7 +99,11 @@ public MemberSignupResponseDto fillExtraInfo(String email, member.updateExtraInfo(dto.getNickname(), dto.getBirthdate(), dto.getGender(), - dto.getIntroduction()); + dto.getIntroduction(), + dto.getEmail(), + dto.getPassword(), + dto.getName(), + dto.getPhone()); return MemberSignupResponseDto.from(member); } diff --git a/src/main/java/com/arom/with_travel/global/config/SecurityConfig.java b/src/main/java/com/arom/with_travel/global/config/SecurityConfig.java index efdac23..3df7651 100644 --- a/src/main/java/com/arom/with_travel/global/config/SecurityConfig.java +++ b/src/main/java/com/arom/with_travel/global/config/SecurityConfig.java @@ -20,6 +20,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -52,7 +53,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/v1/signup/**").permitAll() .requestMatchers("/", "/index/**", "/index.js", "/favicon.ico", "/templates", "/error", "/v3/api-docs/**", "/swagger-ui/**", "/api/v1/login", - "/actuator/**").permitAll() + "/actuator/**","/api/v1/auth/**").permitAll() .anyRequest().authenticated() ) .exceptionHandling((exceptionHandling) -> exceptionHandling @@ -128,6 +129,11 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean public JwtFilter jwtFilter() { return new JwtFilter(jwtProvider, memberDetailsService, securityContextRepository()); diff --git a/src/main/java/com/arom/with_travel/global/exception/error/ErrorCode.java b/src/main/java/com/arom/with_travel/global/exception/error/ErrorCode.java index 26a8b8e..bdec348 100644 --- a/src/main/java/com/arom/with_travel/global/exception/error/ErrorCode.java +++ b/src/main/java/com/arom/with_travel/global/exception/error/ErrorCode.java @@ -74,7 +74,12 @@ public enum ErrorCode { // reply REPLY_NOT_FOUND("REP-0000", "해당 댓글이 존재하지 않습니다.", ErrorDisplayType.POPUP), - REPLY_FORBIDDEN("REP-0001", "해당 댓글에 수정 및 삭제 권한이 없습니다.", ErrorDisplayType.POPUP) + REPLY_FORBIDDEN("REP-0001", "해당 댓글에 수정 및 삭제 권한이 없습니다.", ErrorDisplayType.POPUP), + + // login + DUPLICATED_EMAIL("LOGIN-0001", "중복된 이메일이 존재합니다.", ErrorDisplayType.POPUP), + INVALID_CREDENTIALS("LOGIN-0002", "비밀번호가 올바르지 않습니다.", ErrorDisplayType.POPUP), + LOGIN_FAIL("LOGIN-0003", "로그인 과정이 정상적으로 이루어지지 않았습니다.", ErrorDisplayType.POPUP) ; private final String code;