diff --git a/build.gradle b/build.gradle index 9a826ac..e517a8b 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,14 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.1.RELEASE' + + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } sourceSets { diff --git a/src/main/java/javalab/umc7th_mission/config/security/CustomOAuth2UserService.java b/src/main/java/javalab/umc7th_mission/config/security/CustomOAuth2UserService.java new file mode 100644 index 0000000..2d6698f --- /dev/null +++ b/src/main/java/javalab/umc7th_mission/config/security/CustomOAuth2UserService.java @@ -0,0 +1,67 @@ +package javalab.umc7th_mission.config.security; + +import javalab.umc7th_mission.domain.Member; +import javalab.umc7th_mission.domain.enums.Gender; +import javalab.umc7th_mission.domain.enums.Role; +import javalab.umc7th_mission.repository.MemberRepository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + Map attributes = oAuth2User.getAttributes(); + Map properties = (Map) attributes.get("properties"); + + String nickname = (String) properties.get("nickname"); + String email = nickname + "@kakao.com"; // 임시 이메일 생성 + + // 사용자 정보 저장 또는 업데이트 + Member member = saveOrUpdateUser(email, nickname); + + // 이메일을 Principal로 사용하기 위해 attributes 수정 + Map modifiedAttributes = new HashMap<>(attributes); + modifiedAttributes.put("email", email); + + return new DefaultOAuth2User( + oAuth2User.getAuthorities(), + modifiedAttributes, + "email" // email Principal로 설정 + ); + } + + private Member saveOrUpdateUser(String email, String nickname) { + Member member = memberRepository.findByEmail(email) + .orElse(Member.builder() + .email(email) + .name(nickname) + .password(passwordEncoder.encode("OAUTH_USER_" + UUID.randomUUID())) + .gender(Gender.NONE) // 기본값 설정 + .address("소셜로그인") // 기본값 설정 + .specAddress("소셜로그인") // 기본값 설정 + .birth(LocalDate.now()) + .role(Role.USER) + .build()); + + return memberRepository.save(member); + } +} diff --git a/src/main/java/javalab/umc7th_mission/config/security/CustomUserDetailsService.java b/src/main/java/javalab/umc7th_mission/config/security/CustomUserDetailsService.java new file mode 100644 index 0000000..0748c07 --- /dev/null +++ b/src/main/java/javalab/umc7th_mission/config/security/CustomUserDetailsService.java @@ -0,0 +1,27 @@ +package javalab.umc7th_mission.config.security; + +import javalab.umc7th_mission.domain.Member; +import javalab.umc7th_mission.repository.MemberRepository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(username).orElseThrow(() -> new UsernameNotFoundException("해당 이메일이 존재하지 않습니다.")); + + return User.withUsername(member.getEmail()) + .password(member.getPassword()) + .roles(member.getRole().name()) + .build(); + } +} diff --git a/src/main/java/javalab/umc7th_mission/config/security/SecurityConfig.java b/src/main/java/javalab/umc7th_mission/config/security/SecurityConfig.java new file mode 100644 index 0000000..28892b3 --- /dev/null +++ b/src/main/java/javalab/umc7th_mission/config/security/SecurityConfig.java @@ -0,0 +1,46 @@ +package javalab.umc7th_mission.config.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@EnableWebSecurity +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((requests) -> requests + .requestMatchers("/", "/home", "/signup", "/members/signup", "/css/**").permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + .formLogin((form) -> form + .loginPage("/login") + .defaultSuccessUrl("/home", true) + .permitAll() + ) + .logout((logout) -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll() + ) + .oauth2Login(oauth2 -> oauth2 + .loginPage("/login") + .defaultSuccessUrl("/home", true) + .permitAll() + ); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/javalab/umc7th_mission/converter/MemberConverter.java b/src/main/java/javalab/umc7th_mission/converter/MemberConverter.java new file mode 100644 index 0000000..cf40916 --- /dev/null +++ b/src/main/java/javalab/umc7th_mission/converter/MemberConverter.java @@ -0,0 +1,30 @@ +package javalab.umc7th_mission.converter; + +import javalab.umc7th_mission.domain.Member; +import javalab.umc7th_mission.domain.enums.Gender; +import javalab.umc7th_mission.web.dto.Member.MemberRequestDTO; + +import java.util.ArrayList; + +public class MemberConverter { + public static Member toMember(MemberRequestDTO.JoinDto request) { + Gender gender = null; + switch (request.getGender()) { + case 1: gender = Gender.MALE; break; + case 2: gender = Gender.FEMALE; break; + case 3: gender = Gender.NONE; break; + } + + return Member.builder() + .name(request.getName()) + .email(request.getEmail()) // 추가된 코드 + .password(request.getPassword()) // 추가된 코드 + .gender(gender) + .birth(request.getBirth()) + .address(request.getAddress()) + .specAddress(request.getSpecAddress()) + .role(request.getRole()) // 추가된 코드 + .memberFoodList(new ArrayList<>()) + .build(); + } +} diff --git a/src/main/java/javalab/umc7th_mission/domain/Member.java b/src/main/java/javalab/umc7th_mission/domain/Member.java index f019cc0..8de2b71 100644 --- a/src/main/java/javalab/umc7th_mission/domain/Member.java +++ b/src/main/java/javalab/umc7th_mission/domain/Member.java @@ -6,6 +6,7 @@ import javalab.umc7th_mission.domain.common.BaseEntity; import javalab.umc7th_mission.domain.enums.Gender; import javalab.umc7th_mission.domain.enums.MemberStatus; +import javalab.umc7th_mission.domain.enums.Role; import javalab.umc7th_mission.domain.enums.SocialType; import javalab.umc7th_mission.domain.mapping.MemberFood; import javalab.umc7th_mission.domain.mapping.MemberTerm; @@ -36,6 +37,12 @@ public class Member extends BaseEntity { @Size(max = 30) private String email; + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + private Role role; + @NotNull @Size(max = 20) private String name; @@ -47,18 +54,19 @@ public class Member extends BaseEntity { @Size(max = 50) private String address; + @NotNull + @Size(max = 50) + private String specAddress; + private Integer point; @Enumerated(EnumType.STRING) - @Size(max = 10) private Gender gender; @Enumerated(EnumType.STRING) - @Size(max = 10) private SocialType socialType; @Enumerated(EnumType.STRING) - @Size(max = 10) @ColumnDefault("'ACTIVE'") private MemberStatus status; @@ -79,4 +87,8 @@ public class Member extends BaseEntity { @OneToMany(mappedBy = "member") @Builder.Default private List memberFoodList = new ArrayList<>(); + + public void encodePassword(String password) { + this.password = password; + } } diff --git a/src/main/java/javalab/umc7th_mission/domain/enums/Role.java b/src/main/java/javalab/umc7th_mission/domain/enums/Role.java new file mode 100644 index 0000000..434a246 --- /dev/null +++ b/src/main/java/javalab/umc7th_mission/domain/enums/Role.java @@ -0,0 +1,5 @@ +package javalab.umc7th_mission.domain.enums; + +public enum Role { + ADMIN, USER +} diff --git a/src/main/java/javalab/umc7th_mission/domain/mapping/MemberFood.java b/src/main/java/javalab/umc7th_mission/domain/mapping/MemberFood.java index 77a33f6..4b13bc0 100644 --- a/src/main/java/javalab/umc7th_mission/domain/mapping/MemberFood.java +++ b/src/main/java/javalab/umc7th_mission/domain/mapping/MemberFood.java @@ -24,4 +24,8 @@ public class MemberFood extends BaseEntity { @ManyToOne @JoinColumn(name = "food_id") private FoodCategory foodCategory; + + public void setMember(Member member) { + this.member = member; + } } diff --git a/src/main/java/javalab/umc7th_mission/service/MemberService/MemberCommandService.java b/src/main/java/javalab/umc7th_mission/service/MemberService/MemberCommandService.java new file mode 100644 index 0000000..fb0c086 --- /dev/null +++ b/src/main/java/javalab/umc7th_mission/service/MemberService/MemberCommandService.java @@ -0,0 +1,8 @@ +package javalab.umc7th_mission.service.MemberService; + +import javalab.umc7th_mission.domain.Member; +import javalab.umc7th_mission.web.dto.Member.MemberRequestDTO; + +public interface MemberCommandService { + Member joinMember(MemberRequestDTO.JoinDto request); +} diff --git a/src/main/java/javalab/umc7th_mission/service/MemberService/MemberCommandServiceImpl.java b/src/main/java/javalab/umc7th_mission/service/MemberService/MemberCommandServiceImpl.java new file mode 100644 index 0000000..efb0b75 --- /dev/null +++ b/src/main/java/javalab/umc7th_mission/service/MemberService/MemberCommandServiceImpl.java @@ -0,0 +1,43 @@ +package javalab.umc7th_mission.service.MemberService; + +import javalab.umc7th_mission.apiPayload.code.status.ErrorStatus; +import javalab.umc7th_mission.apiPayload.exception.GeneralException; +import javalab.umc7th_mission.converter.MemberConverter; +import javalab.umc7th_mission.converter.MemberFoodConverter; +import javalab.umc7th_mission.domain.FoodCategory; +import javalab.umc7th_mission.domain.Member; +import javalab.umc7th_mission.domain.mapping.MemberFood; +import javalab.umc7th_mission.repository.FoodCategoryRepository.FoodCategoryRepository; +import javalab.umc7th_mission.repository.MemberRepository.MemberRepository; +import javalab.umc7th_mission.web.dto.Member.MemberRequestDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class MemberCommandServiceImpl implements MemberCommandService { + + private final MemberRepository memberRepository; + private final FoodCategoryRepository foodCategoryRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public Member joinMember(MemberRequestDTO.JoinDto request) { + Member newMember = MemberConverter.toMember(request); + newMember.encodePassword(passwordEncoder.encode(request.getPassword())); + + List foodCategoryList = request.getPreferCategory().stream() + .map(categoryId -> { + return foodCategoryRepository.findById(categoryId).orElseThrow(() -> new GeneralException(ErrorStatus.FOOD_CATEGORY_NOT_FOUND)); + }).collect(Collectors.toList()); + + List memberPreferList = MemberFoodConverter.toMemberPreferList(foodCategoryList); + memberPreferList.forEach(m -> m.setMember(newMember)); + + return memberRepository.save(newMember); + } +} diff --git a/src/main/java/javalab/umc7th_mission/web/controller/MemberViewController.java b/src/main/java/javalab/umc7th_mission/web/controller/MemberViewController.java new file mode 100644 index 0000000..9b55f72 --- /dev/null +++ b/src/main/java/javalab/umc7th_mission/web/controller/MemberViewController.java @@ -0,0 +1,58 @@ +package javalab.umc7th_mission.web.controller; + +import javalab.umc7th_mission.service.MemberService.MemberCommandService; +import javalab.umc7th_mission.web.dto.Member.MemberRequestDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; + +@Controller +@RequiredArgsConstructor +public class MemberViewController { + + private final MemberCommandService memberCommandService; + + @PostMapping("/members/signup") + public String joinMember(@ModelAttribute("memberJoinDto") MemberRequestDTO.JoinDto request, // 협업시에는 기존 RequestBody 어노테이션을 붙여주시면 됩니다! + BindingResult bindingResult, + Model model) { + if (bindingResult.hasErrors()) { + // 뷰에 데이터 바인딩이 실패할 경우 signup 페이지를 유지합니다. + return "signup"; + } + + try { + memberCommandService.joinMember(request); + return "redirect:/login"; + } catch (Exception e) { + // 회원가입 과정에서 에러가 발생할 경우 에러 메시지를 보내고, signup 페이디를 유지합니다. + model.addAttribute("error", e.getMessage()); + return "signup"; + } + } + + @GetMapping("/login") + public String loginPage() { + return "login"; + } + + @GetMapping("/signup") + public String signupPage(Model model) { + model.addAttribute("memberJoinDto", new MemberRequestDTO.JoinDto()); + return "signup"; + } + + @GetMapping("/home") + public String home() { + return "home"; + } + + @GetMapping("/admin") + public String admin() { + return "admin"; + } +} diff --git a/src/main/java/javalab/umc7th_mission/web/dto/Member/MemberRequestDTO.java b/src/main/java/javalab/umc7th_mission/web/dto/Member/MemberRequestDTO.java new file mode 100644 index 0000000..5b2991d --- /dev/null +++ b/src/main/java/javalab/umc7th_mission/web/dto/Member/MemberRequestDTO.java @@ -0,0 +1,37 @@ +package javalab.umc7th_mission.web.dto.Member; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import javalab.umc7th_mission.domain.enums.Role; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.List; + +public class MemberRequestDTO { + @Getter + @Setter // thymeleaf에서 사용하기 위해 추가 + public static class JoinDto { + @NotBlank + String name; + @NotBlank + @Email + String email; // 이메일 필드 추가 + @NotBlank + String password; // 비밀번호 필드 추가 + @NotNull + Integer gender; + @NotNull + LocalDate birth; + @Size(min = 5, max = 12) + String address; + @Size(min = 5, max = 12) + String specAddress; + List preferCategory; + @NotNull + Role role; // 역할 필드 추가 + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index efaaece..e55cda6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,5 +17,23 @@ spring: hbm2ddl: auto: update default_batch_fetch_size: 1000 + security: + oauth2: + client: + registration: + kakao: + client-authentication-method: client_secret_post + client-id: a24e587524185ce07aab2d48b2da06e6 + client-secret: 78CEsJyJSVu5g3aJoYJN6egdBL70uigD + redirect-uri: http://localhost:8080/login/oauth2/code/kakao + authorization-grant-type: authorization_code + scope: profile_nickname + client-name: Kakao + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id springdoc: use-fqn: true \ No newline at end of file diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html new file mode 100644 index 0000000..55dbff1 --- /dev/null +++ b/src/main/resources/templates/admin.html @@ -0,0 +1,10 @@ + + + + Admin Page + + +

Admin Page

+

관리자만 접근할 수 있는 페이지입니다.

+ + \ No newline at end of file diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html new file mode 100644 index 0000000..529b72c --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,17 @@ + + + + Home + + +

Welcome to Home Page!

+

+ + + +
+ +
+ \ No newline at end of file diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 0000000..5468188 --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,25 @@ + + + + Login + + +

Login

+
+
+ + +
+
+ + +
+ +
+

사용자 이름 또는 비밀번호가 잘못되었습니다.

+

로그아웃되었습니다.

+ +

계정이 없나요? Sign up

+카카오로 로그인 + + \ 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..95fc283 --- /dev/null +++ b/src/main/resources/templates/signup.html @@ -0,0 +1,63 @@ + + + + 회원가입 + + + +

회원가입

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+ + +
+ +
+ + \ No newline at end of file