diff --git a/build.gradle b/build.gradle index 1474d23..7b67a0d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,39 +1,53 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.3.5' - id 'io.spring.dependency-management' version '1.1.6' + id 'java' + id 'org.springframework.boot' version '3.3.5' + id 'io.spring.dependency-management' version '1.1.6' } group = 'GDG.DJU' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - // implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - // runtimeOnly 'com.mysql:mysql-connector-j' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - implementation group : 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.2.0' + runtimeOnly 'com.mysql:mysql-connector-j' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.2.0' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'org.projectlombok:lombok' + implementation 'com.google.guava:guava:31.1-jre' + + developmentOnly("org.springframework.boot:spring-boot-devtools") + + testImplementation 'org.springframework.boot:spring-boot-starter-test' +// testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.assertj:assertj-core:3.24.2' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0' + } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/src/main/java/gdg/waffle/BE/common/DatabaseCleanUp.java b/src/main/java/gdg/waffle/BE/common/DatabaseCleanUp.java new file mode 100644 index 0000000..55afb53 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/common/DatabaseCleanUp.java @@ -0,0 +1,45 @@ +package gdg.waffle.BE.common; + +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.google.common.base.CaseFormat; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DatabaseCleanUp implements InitializingBean { + + private final EntityManager entityManager; + private List tableNames = new ArrayList<>(); + + @Override + public void afterPropertiesSet() throws Exception { + tableNames = entityManager.getMetamodel().getEntities().stream() + .filter(entityType -> entityType.getJavaType().getAnnotation(Entity.class) != null) + .map(entityType -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entityType.getName())) + .collect(Collectors.toList()); + } + + @Transactional + public void truncateAllEntity() { + entityManager.flush(); + + + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); + for (String tableName : tableNames) { + entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); + + } + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); + } + +} diff --git a/src/main/java/gdg/waffle/BE/common/exception/CustomException.java b/src/main/java/gdg/waffle/BE/common/exception/CustomException.java new file mode 100644 index 0000000..30006b6 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/common/exception/CustomException.java @@ -0,0 +1,4 @@ +package gdg.waffle.BE.common.exception; + +public class CustomException { +} diff --git a/src/main/java/gdg/waffle/BE/common/jwt/JwtAuthenticationFilter.java b/src/main/java/gdg/waffle/BE/common/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..0519386 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/common/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,42 @@ +package gdg.waffle.BE.common.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.GenericFilterBean; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends GenericFilterBean { + private final JwtTokenProvider jwtTokenProvider; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + // 1. Request Header에서 JWT 토큰 추출 + String token = resolveToken((HttpServletRequest) request); + + // 2. validateToken으로 토큰 유효성 검사 + if (token != null && jwtTokenProvider.validateToken(token)) { + // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장 + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + chain.doFilter(request, response); + } + + // Request Header에서 토큰 정보 추출 + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) { + return bearerToken.substring(7); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/gdg/waffle/BE/common/jwt/JwtToken.java b/src/main/java/gdg/waffle/BE/common/jwt/JwtToken.java new file mode 100644 index 0000000..ec4fc43 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/common/jwt/JwtToken.java @@ -0,0 +1,14 @@ +package gdg.waffle.BE.common.jwt; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Builder +@Data +@AllArgsConstructor +public class JwtToken { + private String grantType; + private String accessToken; + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/gdg/waffle/BE/common/jwt/JwtTokenProvider.java b/src/main/java/gdg/waffle/BE/common/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..ba5adbe --- /dev/null +++ b/src/main/java/gdg/waffle/BE/common/jwt/JwtTokenProvider.java @@ -0,0 +1,118 @@ +package gdg.waffle.BE.common.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class JwtTokenProvider { + private final Key key; + + // application.yml에서 secret 값 가져와서 key에 저장 + public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + // Member 정보를 가지고 AccessToken, RefreshToken을 생성하는 메서드 + public JwtToken generateToken(Authentication authentication) { + // 권한 가져오기 + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + long now = (new Date()).getTime(); + + // Access Token 생성 + Date accessTokenExpiresIn = new Date(now + 86400000); + String accessToken = Jwts.builder() + .setSubject(authentication.getName()) + .claim("auth", authorities) + .setExpiration(accessTokenExpiresIn) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + // Refresh Token 생성 + String refreshToken = Jwts.builder() + .setExpiration(new Date(now + 86400000)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + return JwtToken.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + // Jwt 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드 + public Authentication getAuthentication(String accessToken) { + // Jwt 토큰 복호화 + Claims claims = parseClaims(accessToken); + + if (claims.get("auth") == null) { + throw new RuntimeException("권한 정보가 없는 토큰입니다."); + } + + // 클레임에서 권한 정보 가져오기 + Collection authorities = Arrays.stream(claims.get("auth").toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + // UserDetails 객체를 만들어서 Authentication return + // UserDetails: interface, User: UserDetails를 구현한 class + UserDetails principal = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } + + // 토큰 정보를 검증하는 메서드 + public boolean validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + return true; + } catch (SecurityException | MalformedJwtException e) { + log.info("Invalid JWT Token", e); + } catch (ExpiredJwtException e) { + log.info("Expired JWT Token", e); + } catch (UnsupportedJwtException e) { + log.info("Unsupported JWT Token", e); + } catch (IllegalArgumentException e) { + log.info("JWT claims string is empty.", e); + } + return false; + } + + + // accessToken + private Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(accessToken) + .getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + +} \ No newline at end of file diff --git a/src/main/java/gdg/waffle/BE/common/jwt/TestComponent.java b/src/main/java/gdg/waffle/BE/common/jwt/TestComponent.java new file mode 100644 index 0000000..81a4a80 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/common/jwt/TestComponent.java @@ -0,0 +1,11 @@ +package gdg.waffle.BE.common.jwt; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class TestComponent { + public TestComponent(@Value("${jwt.secret}") String secret) { + System.out.println("JWT Secret: " + secret); // 읽은 값을 출력 + } +} \ No newline at end of file diff --git a/src/main/java/gdg/waffle/BE/config/SecurityConfig.java b/src/main/java/gdg/waffle/BE/config/SecurityConfig.java new file mode 100644 index 0000000..b8d2e95 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/config/SecurityConfig.java @@ -0,0 +1,56 @@ +package gdg.waffle.BE.config; + +import gdg.waffle.BE.common.jwt.JwtAuthenticationFilter; +import gdg.waffle.BE.common.jwt.JwtTokenProvider; +import gdg.waffle.BE.login.service.CustomUserDetailsService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final JwtTokenProvider jwtTokenProvider; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + return httpSecurity + // REST API이므로 basic auth 및 csrf 보안을 사용하지 않음 + .httpBasic().disable() + .csrf().disable() + // JWT를 사용하기 때문에 세션을 사용하지 않음 + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .authorizeHttpRequests() + // 해당 API에 대해서는 모든 요청을 허가 + .requestMatchers("/members/login").permitAll() // 로그인 페이지 이동 + .requestMatchers("/members/home").permitAll() // 홈 화면 이동 + .requestMatchers("/members/sign-up").permitAll() // 회원가입 + .requestMatchers("/members/sign-in").permitAll() // 로그인 + // USER 권한이 있어야 요청할 수 있음 +// .requestMatchers("/members/test").hasRole("USER") + // 이 밖에 모든 요청에 대해서 인증을 필요로 한다는 설정 + .anyRequest().authenticated() + .and() + // JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행 + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class).build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + // BCrypt Encoder 사용 + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + +} \ No newline at end of file diff --git a/src/main/java/gdg/waffle/BE/login/controller/MemberApiController.java b/src/main/java/gdg/waffle/BE/login/controller/MemberApiController.java new file mode 100644 index 0000000..a790d17 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/login/controller/MemberApiController.java @@ -0,0 +1,53 @@ +package gdg.waffle.BE.login.controller; + +import gdg.waffle.BE.common.jwt.JwtToken; +import gdg.waffle.BE.login.domain.MemberDto; +import gdg.waffle.BE.login.domain.SignInDto; +import gdg.waffle.BE.login.domain.SignUpDto; +import gdg.waffle.BE.login.service.MemberService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/members") +public class MemberApiController { + private final MemberService memberService; + + @PostMapping("/sign-in") + public JwtToken signIn(@RequestBody SignInDto signInDto) { + String loginId = signInDto.getLoginId(); + String password = signInDto.getPassword(); + + log.info("로그인 아이디 : {}", loginId); + log.info("로그인 비밀번호 : {}", password); + + JwtToken jwtToken = memberService.signIn(signInDto); + log.info("request username = {}, password = {}", loginId, password); + log.info("jwtToken accessToken = {}, refreshToken = {}", jwtToken.getAccessToken(), jwtToken.getRefreshToken()); + return jwtToken; + } + + @PostMapping("/sign-up") + public ResponseEntity signUp(@RequestBody SignUpDto signUpDto) { + try { + memberService.signUp(signUpDto); + return ResponseEntity.ok("회원가입이 완료되었습니다."); + } catch (IllegalArgumentException e) { + log.error("회원가입 실패: {}", e.getMessage()); + return ResponseEntity.badRequest().body("회원가입 실패: " + e.getMessage()); + } catch (Exception e) { + log.error("서버 오류: {}", e.getMessage()); + return ResponseEntity.status(500).body("서버 오류로 인해 회원가입에 실패했습니다."); + } + } + + @PostMapping("/test") + public String test() { + return "success"; + } + +} \ No newline at end of file diff --git a/src/main/java/gdg/waffle/BE/login/controller/MemberViewController.java b/src/main/java/gdg/waffle/BE/login/controller/MemberViewController.java new file mode 100644 index 0000000..0482a33 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/login/controller/MemberViewController.java @@ -0,0 +1,32 @@ +package gdg.waffle.BE.login.controller; + +import gdg.waffle.BE.login.service.MemberService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@Controller +@RequiredArgsConstructor +@RequestMapping("/members") +public class MemberViewController { + private final MemberService memberService; + + @GetMapping("/login") // 로그인 페이지 이동 + public String loginPage() { + return "login"; + } + + @GetMapping("/sign-up") // 회원가입 페이지 이동 + public String signUpPage() { + return "sign-up"; + } + + @GetMapping("/home") // 홈화면 이동 + public String homePage() { + return "home"; + } +} + + diff --git a/src/main/java/gdg/waffle/BE/login/domain/Member.java b/src/main/java/gdg/waffle/BE/login/domain/Member.java new file mode 100644 index 0000000..e3cef44 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/login/domain/Member.java @@ -0,0 +1,101 @@ +package gdg.waffle.BE.login.domain; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +@Entity +@Table(name= "members") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@EqualsAndHashCode(of = "id") +public class Member implements UserDetails { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id", updatable = false, unique = true, nullable = false) + private Long id; + + @Column(nullable = false) + private String loginId; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String nickName; + + private LocalDate birth; + + @Column(nullable = false) + private String phone; + + @Column(nullable = false) + private String email; + + @Column(nullable = false) + private String address; // 도로명 주소 + + private String detailAddress; + + @Column(nullable = false) + @ElementCollection(fetch = FetchType.EAGER) + @Builder.Default + private List roles = new ArrayList<>(); + @Override + public Collection getAuthorities() { + return this.roles.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + @Column(nullable = false) + private String status; + + private LocalDateTime registrationDate; + + private LocalDateTime lastModified; + + @Override + public String getUsername() { + return loginId; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} \ No newline at end of file diff --git a/src/main/java/gdg/waffle/BE/login/domain/MemberDto.java b/src/main/java/gdg/waffle/BE/login/domain/MemberDto.java new file mode 100644 index 0000000..8816adc --- /dev/null +++ b/src/main/java/gdg/waffle/BE/login/domain/MemberDto.java @@ -0,0 +1,67 @@ +package gdg.waffle.BE.login.domain; + +import lombok.*; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@ToString +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class MemberDto { + + private Long id; + + private String loginId; + + private String password; + + private String name; + + private String nickName; + + private LocalDate birth; + + private String phone; + + private String email; + + private String address; // 도로명 주소 + + private String detailAddress; + + private List roles; + + private String status; + + private LocalDateTime registrationDate; + + private LocalDateTime lastModified; + + // 회원가입 후 이름, 닉네임, 가입일자를 유저에게 보여줌 + static public MemberDto toDtoForSignUp(Member member) { + return MemberDto.builder() + .name(member.getName()) + .nickName(member.getNickName()) + .registrationDate(member.getRegistrationDate()) + .build(); + } + +// public Member toEntity() { +// return Member.builder() +// .id(id) +// .loginId(loginId) +// .name(name) +// .nickName(nickName) +// .birth(birth) +// .phone(phone) +// .email(email) +// .address(address) +// .detailAddress(detailAddress) +// .status(status) +// .registrationDate(registrationDate) +// .lastModified(lastModified); +// } +} \ No newline at end of file diff --git a/src/main/java/gdg/waffle/BE/login/domain/SignInDto.java b/src/main/java/gdg/waffle/BE/login/domain/SignInDto.java new file mode 100644 index 0000000..5ac8582 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/login/domain/SignInDto.java @@ -0,0 +1,22 @@ +package gdg.waffle.BE.login.domain; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@NoArgsConstructor +public class SignInDto { + private String loginId; + private String password; + + @Builder + public SignInDto(String loginId, String password) { + this.loginId = loginId; + this.password = password; + } +} diff --git a/src/main/java/gdg/waffle/BE/login/domain/SignUpDto.java b/src/main/java/gdg/waffle/BE/login/domain/SignUpDto.java new file mode 100644 index 0000000..8642931 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/login/domain/SignUpDto.java @@ -0,0 +1,61 @@ +package gdg.waffle.BE.login.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SignUpDto { + + private String loginId; + + private String password; + + private String name; + + private String nickName; + + private LocalDate birth; + + private String phone; + + private String email; + + private String address; + + private String detailAddress; + + private List roles; + + private String status; + + private LocalDateTime registrationDate; + + private LocalDateTime lastModified; + + public Member toEntity(String encodedPassword, List roles) { + return Member.builder() + .loginId(loginId) + .password(encodedPassword) + .name(name) + .nickName(nickName) + .birth(birth) + .phone(phone) + .email(email) + .address(address) + .detailAddress(detailAddress) + .roles(roles) + .status("ACTIVE") + .registrationDate(LocalDateTime.now()) + .lastModified(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/gdg/waffle/BE/login/repository/MemberRepository.java b/src/main/java/gdg/waffle/BE/login/repository/MemberRepository.java new file mode 100644 index 0000000..afbd355 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/login/repository/MemberRepository.java @@ -0,0 +1,11 @@ +package gdg.waffle.BE.login.repository; + +import gdg.waffle.BE.login.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} \ No newline at end of file diff --git a/src/main/java/gdg/waffle/BE/login/service/CustomUserDetailsService.java b/src/main/java/gdg/waffle/BE/login/service/CustomUserDetailsService.java new file mode 100644 index 0000000..4de4ef7 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/login/service/CustomUserDetailsService.java @@ -0,0 +1,36 @@ +package gdg.waffle.BE.login.service; + +import gdg.waffle.BE.login.domain.Member; +import gdg.waffle.BE.login.repository.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.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException { + return memberRepository.findByLoginId(loginId) + .map(this::createUserDetails) + .orElseThrow(() -> new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다.")); + } + + // 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 return + private UserDetails createUserDetails(Member member) { + return User.builder() + .username(member.getLoginId()) + .password(member.getPassword()) + .roles(member.getRoles().toArray(new String[0])) + .build(); + } + +} \ No newline at end of file diff --git a/src/main/java/gdg/waffle/BE/login/service/MemberService.java b/src/main/java/gdg/waffle/BE/login/service/MemberService.java new file mode 100644 index 0000000..28ffd32 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/login/service/MemberService.java @@ -0,0 +1,19 @@ +package gdg.waffle.BE.login.service; + +import gdg.waffle.BE.common.jwt.JwtToken; +import gdg.waffle.BE.login.domain.MemberDto; +import gdg.waffle.BE.login.domain.SignInDto; +import gdg.waffle.BE.login.domain.SignUpDto; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +public interface MemberService { + /** + * 사용자의 아이디와 비밀번호로 로그인을 처리하고 JWT 토큰을 반환. + * @param signInDto 로그인 정보 (username, password 포함) + * @return 생성된 JWT 토큰 + */ + JwtToken signIn(SignInDto signInDto); + void signUp(SignUpDto signUpDto); +} + diff --git a/src/main/java/gdg/waffle/BE/login/service/MemberServiceImpl.java b/src/main/java/gdg/waffle/BE/login/service/MemberServiceImpl.java new file mode 100644 index 0000000..6387602 --- /dev/null +++ b/src/main/java/gdg/waffle/BE/login/service/MemberServiceImpl.java @@ -0,0 +1,62 @@ +package gdg.waffle.BE.login.service; + +import ch.qos.logback.classic.encoder.JsonEncoder; +import gdg.waffle.BE.common.jwt.JwtToken; +import gdg.waffle.BE.common.jwt.JwtTokenProvider; +import gdg.waffle.BE.login.domain.MemberDto; +import gdg.waffle.BE.login.domain.SignInDto; +import gdg.waffle.BE.login.domain.SignUpDto; +import gdg.waffle.BE.login.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class MemberServiceImpl implements MemberService{ + private final MemberRepository memberRepository; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final JwtTokenProvider jwtTokenProvider; + private final PasswordEncoder passwordEncoder; // 올바른 PasswordEncoder 사용 + + @Transactional + @Override + public JwtToken signIn(SignInDto signInDto) { + String loginId = signInDto.getLoginId(); + String password = signInDto.getPassword(); + // 1. username + password 를 기반으로 Authentication 객체 생성 + // 이때 authentication 은 인증 여부를 확인하는 authenticated 값이 false + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginId, password); + + // 2. 실제 검증. authenticate() 메서드를 통해 요청된 Member 에 대한 검증 진행 + // authenticate 메서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드 실행 + Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); + + // 3. 인증 정보를 기반으로 JWT 토큰 생성 + return jwtTokenProvider.generateToken(authentication); + + } + + @Transactional + @Override + public void signUp(SignUpDto signUpDto) { + if (memberRepository.existsByLoginId(signUpDto.getLoginId())) { + throw new IllegalArgumentException("이미 사용 중인 사용자 이름입니다."); + } + // Password 암호화 + String encodedPassword = passwordEncoder.encode(signUpDto.getPassword()); + List roles = new ArrayList<>(); + roles.add("USER"); // USER 권한 부여 + memberRepository.save(signUpDto.toEntity(encodedPassword, roles)); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 88049e7..59ff98c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,14 @@ -spring.application.name=GDG-Waffle-BE +#spring.application.name=GDG-Waffle-BE +# +#spring.datasource.url=jdbc:mysql://localhost:3306/waffle_univ +#spring.datasource.username=root +#spring.datasource.password=1234 +#spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +#spring.jpa.hibernate.ddl-auto=update +#spring.jpa.show-sql=true +#spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect +# +#spring.thymeleaf.enabled=true +#spring.thymeleaf.prefix=classpath:/templates/ +#spring.thymeleaf.suffix=.html +# diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..b397cf6 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,16 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3306/waffleuniv + driver-class-name: com.mysql.cj.jdbc.Driver + username: root + password: 1234 + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + properties: + hibernate: + show-sql: true + format-sql: true + hibernate: + ddl-auto: update + jwt: + secret: 64461f01e1af406da538b9c48d801ce59142452199ff112fb5404c8e7e98e3ff \ 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..5482ae0 --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,46 @@ + + + + + + Home + + +

홈 화면

+
+ + +
+ + + + diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 0000000..4ab6542 --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,51 @@ + + + + + + Login and Signup + + +
+
+

+ +
+

+ + + +
+ + + + diff --git a/src/main/resources/templates/sign-up.html b/src/main/resources/templates/sign-up.html new file mode 100644 index 0000000..14be503 --- /dev/null +++ b/src/main/resources/templates/sign-up.html @@ -0,0 +1,77 @@ + + + + + + 회원가입 + + +

회원가입

+
+ +

+ + +

+ + +

+ + +

+ + +

+ + +

+ + +

+ + +

+ + +

+ + +
+ + + + diff --git a/src/test/java/GDG_Waffle_BE/JwtTokenProviderTest.java b/src/test/java/GDG_Waffle_BE/JwtTokenProviderTest.java new file mode 100644 index 0000000..f9b35a4 --- /dev/null +++ b/src/test/java/GDG_Waffle_BE/JwtTokenProviderTest.java @@ -0,0 +1,20 @@ +//package GDG_Waffle_BE; +// +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.boot.test.context.SpringBootTest; +// +//import static org.junit.jupiter.api.Assertions.assertNotNull; +// +//@SpringBootTest // Spring Boot 테스트 환경 설정 +//public class JwtTokenProviderTest { +// +// @Value("${jwt.secret}") // application.yml에서 jwt.secret 값 읽기 +// private String secret; +// +// @Test +// void testJwtSecretLoading() { +// System.out.println("JWT Secret: " + secret); // 콘솔에 출력 +// assertNotNull(secret, "JWT secret 값이 null입니다!"); // 값이 null이 아니어야 성공 +// } +//} diff --git a/src/test/java/GDG_Waffle_BE/MemberControllerTest.java b/src/test/java/GDG_Waffle_BE/MemberControllerTest.java new file mode 100644 index 0000000..45ee514 --- /dev/null +++ b/src/test/java/GDG_Waffle_BE/MemberControllerTest.java @@ -0,0 +1,98 @@ +//package GDG_Waffle_BE; +// +// +//import gdg.waffle.BE.common.DatabaseCleanUp; +//import gdg.waffle.BE.common.jwt.JwtToken; +//import gdg.waffle.BE.login.domain.MemberDto; +//import gdg.waffle.BE.login.domain.SignInDto; +//import gdg.waffle.BE.login.domain.SignUpDto; +//import gdg.waffle.BE.login.service.MemberService; +//import lombok.extern.slf4j.Slf4j; +//import org.junit.jupiter.api.AfterEach; +//import org.junit.jupiter.api.BeforeEach; +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.context.SpringBootTest; +//import org.springframework.boot.test.web.client.TestRestTemplate; +//import org.springframework.boot.test.web.server.LocalServerPort; +//import org.springframework.http.*; +//import org.springframework.stereotype.Component; +// +//import static org.assertj.core.api.Assertions.assertThat; +// +//@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +//@Slf4j +//class MemberControllerTest { +// +// @Autowired +// DatabaseCleanUp databaseCleanUp; +// @Autowired +// MemberService memberService; +// @Autowired +// TestRestTemplate testRestTemplate; +// @LocalServerPort +// int randomServerPort; +// +// private SignUpDto signUpDto; +// +// @BeforeEach +// void beforeEach() { +// // Member 회원가입 +// signUpDto = SignUpDto.builder() +// .username("member") +// .password("12345678") +// .nickname("닉네임") +// .address("서울시 광진구") +// .phone("010-1234-5678") +// .build(); +// } +// +// @AfterEach +// void afterEach() { +// databaseCleanUp.truncateAllEntity(); +// } +// +// @Test +// public void signUpTest() { +// +// // API 요청 설정 +// String url = "http://localhost:" + randomServerPort + "/members/sign-up"; +// ResponseEntity responseEntity = testRestTemplate.postForEntity(url, signUpDto, MemberDto.class); +// +// // 응답 검증 +// assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); +// MemberDto savedMemberDto = responseEntity.getBody(); +// assertThat(savedMemberDto.getUsername()).isEqualTo(signUpDto.getUsername()); +// assertThat(savedMemberDto.getNickname()).isEqualTo(signUpDto.getNickname()); +// } +// +// @Test +// public void signInTest() { +// memberService.signUp(signUpDto); +// +// SignInDto signInDto = SignInDto.builder() +// .username("member") +// .password("12345678").build(); +// +// // 로그인 요청 +// JwtToken jwtToken = memberService.signIn(signInDto); +// +// // HttpHeaders 객체 생성 및 토큰 추가 +// HttpHeaders httpHeaders = new HttpHeaders(); +// httpHeaders.setBearerAuth(jwtToken.getAccessToken()); +// httpHeaders.setContentType(MediaType.APPLICATION_JSON); +// +//// log.info("httpHeaders = {}", httpHeaders); +// +// // API 요청 설정 +// String url = "http://localhost:" + randomServerPort + "/members/test"; +// ResponseEntity responseEntity = testRestTemplate.postForEntity(url, new HttpEntity<>(httpHeaders), String.class); +// assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); +// assertThat(responseEntity.getBody()).isEqualTo(signInDto.getUsername()); +// +//// assertThat(SecurityUtil.getCurrentUsername()).isEqualTo(signInDto.getUsername()); // -> 테스트 코드에서는 인증을 위한 절차를 거치지 X. SecurityContextHolder 에 인증 정보가 존재하지 않는다. +// +// +// } +// +//}