diff --git a/.gitignore b/.gitignore index c2065bc..8ba835d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +### etc +**/application.properties diff --git a/build.gradle b/build.gradle index 15b77ef..ea30a39 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation group: 'javax.xml.bind', name: 'jaxb-api', version: '2.3.1' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/gradlew b/gradlew old mode 100755 new mode 100644 diff --git a/gradlew.bat b/gradlew.bat index 53a6b23..f127cfd 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,91 +1,91 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/java/com/dku/springstudy/config/JwtAuthenticationFilter.java b/src/main/java/com/dku/springstudy/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..cecf589 --- /dev/null +++ b/src/main/java/com/dku/springstudy/config/JwtAuthenticationFilter.java @@ -0,0 +1,42 @@ +package com.dku.springstudy.config; + +import com.dku.springstudy.jwt.JwtTokenProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtTokenProvider tokenProvider; + @Value("${jwt.secret}") + private String jwtSecretKey; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // 파라미터로 받은 request, response 객체들이 한 번 읽으면 없어지는 애들이라 한 번 감싼 애들을 만들어준다. 일종의 복제 + ContentCachingRequestWrapper wrappingRequest = new ContentCachingRequestWrapper(request); + ContentCachingResponseWrapper wrappingResponse = new ContentCachingResponseWrapper(response); + + String token = tokenProvider.getTokenFromHeader(request); + if (token != null && tokenProvider.validateToken(token, jwtSecretKey)) { + Authentication authentication = tokenProvider.getAuthentication(token, jwtSecretKey); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(wrappingRequest, wrappingResponse); + + // 필터엔 복제한 애들 넘기기. 인터셉터도 래핑된 애들을 파라미터로 받음 + filterChain.doFilter(request, wrappingResponse); + // 마지막에 클라이언트에게 response나갈 때 wrapping한 내용을 써줌(복제본 내용을 원본에 쓴다!) + wrappingResponse.copyBodyToResponse(); + } +} diff --git a/src/main/java/com/dku/springstudy/config/SecurityConfig.java b/src/main/java/com/dku/springstudy/config/SecurityConfig.java new file mode 100644 index 0000000..f1fe9b3 --- /dev/null +++ b/src/main/java/com/dku/springstudy/config/SecurityConfig.java @@ -0,0 +1,43 @@ +package com.dku.springstudy.config; + +import com.dku.springstudy.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +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.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.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity // 기본적인 웹 보안을 활성화하는 어노테이션 +@RequiredArgsConstructor +public class SecurityConfig { + private final JwtTokenProvider tokenProvider; + + @Bean + public PasswordEncoder passwordEncoder(){ + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http.csrf().disable() // CSRF 공격에 대한 방어를 해제 + .cors().and() // cors 허용 + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 서버를 stateless하게 유지 즉 세션 X + .and() + .formLogin().disable() // 시큐리티가 기본 제공하는 로그인 화면 없앰. JWT는 로그인과정을 수동으로 클래스로 만들어야 하니까 + .httpBasic().disable() // 토큰 방식을 이용할 것이므로 보안에 취약한 HttpBasic은 꺼두기 + .authorizeRequests() // HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다 + .requestMatchers("/account/signup", "/account/login", "/").permitAll() // 요 세 놈에 대한 요청은 인증없이 접근 허용 + .anyRequest().authenticated() // 나머지에 대해선 인증을 받아야 한다. + .and() + // 여러 필터들 중 UsernamePassword필터 앞에 내가 만든 필터를 둔다. 이렇게 하면 커스텀 필터로 인가인증을 다룰 수 있음 + .addFilterBefore(new JwtAuthenticationFilter(tokenProvider), + UsernamePasswordAuthenticationFilter.class) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dku/springstudy/config/SpringConfig.java b/src/main/java/com/dku/springstudy/config/SpringConfig.java new file mode 100644 index 0000000..8fa89fc --- /dev/null +++ b/src/main/java/com/dku/springstudy/config/SpringConfig.java @@ -0,0 +1,24 @@ +package com.dku.springstudy.config; + +import com.dku.springstudy.repository.JpaMemberRepository; +import com.dku.springstudy.repository.MemberRepository; +import com.dku.springstudy.service.MemberService; +import jakarta.persistence.EntityManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SpringConfig { + private final EntityManager em; + + @Autowired + public SpringConfig(EntityManager em) { + this.em = em; + } + + @Bean + public MemberRepository memberRepository() { + return new JpaMemberRepository(em); + } +} diff --git a/src/main/java/com/dku/springstudy/config/WebConfig.java b/src/main/java/com/dku/springstudy/config/WebConfig.java new file mode 100644 index 0000000..83b8750 --- /dev/null +++ b/src/main/java/com/dku/springstudy/config/WebConfig.java @@ -0,0 +1,19 @@ +package com.dku.springstudy.config; + +import com.dku.springstudy.interceptor.MyInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + private final MyInterceptor myInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(myInterceptor) + .addPathPatterns("/**"); + } +} diff --git a/src/main/java/com/dku/springstudy/controller/AccountController.java b/src/main/java/com/dku/springstudy/controller/AccountController.java new file mode 100644 index 0000000..9e23472 --- /dev/null +++ b/src/main/java/com/dku/springstudy/controller/AccountController.java @@ -0,0 +1,59 @@ +package com.dku.springstudy.controller; + +import com.dku.springstudy.dto.LoginDTO; +import com.dku.springstudy.dto.LoginResponseDTO; +import com.dku.springstudy.jwt.JwtTokenProvider; +import com.dku.springstudy.model.Member; +import com.dku.springstudy.dto.SignupDTO; +import com.dku.springstudy.model.Role; +import com.dku.springstudy.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = "/account") +@RequiredArgsConstructor +public class AccountController { + private final JwtTokenProvider tokenProvider; + private final PasswordEncoder passwordEncoder; + private final MemberService memberService; + + @Value("${jwt.secret}") + private String jwtSecretKey; + private final long EXPIRED_MS = 30 * 60 * 1000L; + + @PostMapping("/signup") + public String signup(SignupDTO memberForm) { + String rawPassword = memberForm.getPassword(); + String encodedPassword = passwordEncoder.encode(rawPassword); + + Member newMember = new Member(); + newMember.setEmail(memberForm.getEmail()); + newMember.setPassword(encodedPassword); + newMember.setNickname(memberForm.getNickname()); + newMember.setRole(Role.USER); + memberService.join(newMember); + + return "success"; + } + + @PostMapping("/login") + public LoginResponseDTO login(@RequestBody LoginDTO loginInfo) { + String loginEmail = loginInfo.getEmail(); + String loginRawPassword = loginInfo.getPassword(); + if (!memberService.login(loginEmail, loginRawPassword)) { + throw new IllegalStateException("로그인에 실패했습니다."); + } + + String accessToken = tokenProvider.createToken(loginEmail, jwtSecretKey, EXPIRED_MS); + LoginResponseDTO loginResponseDTO = new LoginResponseDTO(accessToken); + + return loginResponseDTO; + } +} diff --git a/src/main/java/com/dku/springstudy/dto/LoginDTO.java b/src/main/java/com/dku/springstudy/dto/LoginDTO.java new file mode 100644 index 0000000..e7e6d04 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/LoginDTO.java @@ -0,0 +1,9 @@ +package com.dku.springstudy.dto; + +import lombok.Data; + +@Data +public class LoginDTO { + private String email; + private String password; +} \ No newline at end of file diff --git a/src/main/java/com/dku/springstudy/dto/LoginResponseDTO.java b/src/main/java/com/dku/springstudy/dto/LoginResponseDTO.java new file mode 100644 index 0000000..42e7458 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/LoginResponseDTO.java @@ -0,0 +1,10 @@ +package com.dku.springstudy.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@AllArgsConstructor +@Data +public class LoginResponseDTO { + private String accessToken; +} diff --git a/src/main/java/com/dku/springstudy/dto/SignupDTO.java b/src/main/java/com/dku/springstudy/dto/SignupDTO.java new file mode 100644 index 0000000..13b3b6a --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/SignupDTO.java @@ -0,0 +1,14 @@ +package com.dku.springstudy.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SignupDTO { + private String email; + private String password; + private String name; + private String phone; + private String nickname; +} diff --git a/src/main/java/com/dku/springstudy/interceptor/MyInterceptor.java b/src/main/java/com/dku/springstudy/interceptor/MyInterceptor.java new file mode 100644 index 0000000..a267355 --- /dev/null +++ b/src/main/java/com/dku/springstudy/interceptor/MyInterceptor.java @@ -0,0 +1,54 @@ +package com.dku.springstudy.interceptor; + +import com.dku.springstudy.util.SuccessResponseDTO; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.util.ContentCachingResponseWrapper; + +@RequiredArgsConstructor +@Component +public class MyInterceptor implements HandlerInterceptor { + private final String SUCCESS_PREFIX = "2"; + private final String JSON_CONTENT_TYPE = "application/json"; + // response를 object로 매핑해야 됨 + private final ObjectMapper objectMapper; + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object handler, Exception ex + ) throws Exception { + // 필터에서 wrapping됐던 response가 산 넘고 강 건너 결국 이 메서드로 넘어올 것임 + final ContentCachingResponseWrapper cachingResponse = (ContentCachingResponseWrapper) response; + // 200번대 응답이 아니면 인터셉터를 거치지 않도록 + if (!isSuccessStatus(response.getStatus())) { + return; + } + if (cachingResponse.getContentType() != null + && cachingResponse.getContentType().contains(JSON_CONTENT_TYPE) + && cachingResponse.getContentAsByteArray().length != 0) { + // String 변환 + String body = new String(cachingResponse.getContentAsByteArray()); + // Object 형식으로 변환 (Response에 꽂아주기 위함) + Object data = objectMapper.readValue(body, Object.class); + // 컨트롤러가 뱉는 모든 DTO들을 형식에 상관없이 ResponseDTO에 담는 것! + SuccessResponseDTO objectSuccessResponseDTO = new SuccessResponseDTO<>(data); + // String 변환 + String wrappedBody = objectMapper.writeValueAsString(objectSuccessResponseDTO); + // 비우고 (원래는 여기에 SuccessResponseDTO가 아닌 다른 놈, 즉 통일된 형식을 갖추기 전의 무언가가 있었을 거니까) + cachingResponse.resetBuffer(); + // 웅답값 교체 + cachingResponse.getOutputStream().write(wrappedBody.getBytes(), 0, wrappedBody.getBytes().length); + } + + } + + private boolean isSuccessStatus(int status) { + return String.valueOf(status).startsWith(SUCCESS_PREFIX); + } +} \ No newline at end of file diff --git a/src/main/java/com/dku/springstudy/jwt/JwtTokenProvider.java b/src/main/java/com/dku/springstudy/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..9c37f37 --- /dev/null +++ b/src/main/java/com/dku/springstudy/jwt/JwtTokenProvider.java @@ -0,0 +1,67 @@ +package com.dku.springstudy.jwt; + +import io.jsonwebtoken.*; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + private final UserDetailsService userDetailsService; + + public String createToken(String userEmail, String jwtSecretKey, long expiredMs) { + // claims : jwt에서 내가 원하는 걸 담는 공간. payload라고 보면 됨. 일종의 map + Claims claims = Jwts.claims(); + claims.put("email", userEmail); + + return Jwts.builder() + .setClaims(claims) // 위에서 만들어둔 claims 넣기 + .setIssuedAt(new Date(System.currentTimeMillis())) // 현재 시간 넣기 + .setExpiration(new Date(System.currentTimeMillis() + expiredMs)) // 종료시간 넣기 + .signWith(SignatureAlgorithm.HS256, jwtSecretKey) // 서명하기 + .compact(); + } + + public boolean validateToken(String token, String jwtSecretKey) { + try { + // 토큰 복호화 + Claims claims = Jwts.parser() // parser 생성 + .setSigningKey(jwtSecretKey) // JWS 디지털 서명을 확인하는 데 쓰일 키를 세팅 + .parseClaimsJws(token) + .getBody(); + return true; + } catch (SignatureException e) { + // throw new IllegalArgumentException("토큰을 만들 때 쓰인 키가 아닙니다"); + return false; + } catch (ExpiredJwtException e) { + return false; + } + } + + public String getTokenFromHeader(HttpServletRequest request) { + return request.getHeader("X-AUTH-TOKEN"); + } + + public String getUserEmailFromToken(String token, String jwtSecretKey) { + // 토큰 복호화 + Claims claims = Jwts.parser() // parser 생성 + .setSigningKey(jwtSecretKey) // JWS 디지털 서명을 확인하는 데 쓰일 키를 세팅 + .parseClaimsJws(token) + .getBody(); + String userEmail = (String)claims.get("email"); // object형태로 저장돼있어서..문자열로 변환해야 함 + return userEmail; + } + + public Authentication getAuthentication(String token, String jwtSecretKey) { + String email = getUserEmailFromToken(token, jwtSecretKey); + UserDetails userDetails = userDetailsService.loadUserByUsername(email); + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + } +} diff --git a/src/main/java/com/dku/springstudy/model/Member.java b/src/main/java/com/dku/springstudy/model/Member.java new file mode 100644 index 0000000..966d99c --- /dev/null +++ b/src/main/java/com/dku/springstudy/model/Member.java @@ -0,0 +1,58 @@ +package com.dku.springstudy.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; + +@Getter +@Setter +@Entity +public class Member implements UserDetails { + @Id + private String email; + private String password; + private String name; + private String phone; + private String nickname; + + @Enumerated(EnumType.STRING) + private Role role; + + @Override + public Collection getAuthorities() { + Collection authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority(getRole().name())); + return authorities; + } + + @Override + public String getUsername() { + return email; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/dku/springstudy/model/Role.java b/src/main/java/com/dku/springstudy/model/Role.java new file mode 100644 index 0000000..9ce805a --- /dev/null +++ b/src/main/java/com/dku/springstudy/model/Role.java @@ -0,0 +1,5 @@ +package com.dku.springstudy.model; + +public enum Role { + USER, ADMIN, GUEST; +} diff --git a/src/main/java/com/dku/springstudy/repository/JpaMemberRepository.java b/src/main/java/com/dku/springstudy/repository/JpaMemberRepository.java new file mode 100644 index 0000000..a4723da --- /dev/null +++ b/src/main/java/com/dku/springstudy/repository/JpaMemberRepository.java @@ -0,0 +1,26 @@ +package com.dku.springstudy.repository; + +import com.dku.springstudy.model.Member; +import jakarta.persistence.EntityManager; + +import java.util.Optional; + +public class JpaMemberRepository implements MemberRepository { + private final EntityManager em; + + public JpaMemberRepository(EntityManager em) { + this.em = em; + } + + @Override + public Member save(Member newMember) { + em.persist(newMember); + return newMember; + } + + @Override + public Optional findByEmail(String email) { + Member member = em.find(Member.class, email); + return Optional.ofNullable(member); + } +} diff --git a/src/main/java/com/dku/springstudy/repository/MemberRepository.java b/src/main/java/com/dku/springstudy/repository/MemberRepository.java new file mode 100644 index 0000000..669fc19 --- /dev/null +++ b/src/main/java/com/dku/springstudy/repository/MemberRepository.java @@ -0,0 +1,11 @@ +package com.dku.springstudy.repository; + +import com.dku.springstudy.model.Member; + +import java.util.Optional; + +public interface MemberRepository { + Member save(Member newMember); + Optional findByEmail(String email); +} + diff --git a/src/main/java/com/dku/springstudy/service/CustomUserDetailService.java b/src/main/java/com/dku/springstudy/service/CustomUserDetailService.java new file mode 100644 index 0000000..8aafd0d --- /dev/null +++ b/src/main/java/com/dku/springstudy/service/CustomUserDetailService.java @@ -0,0 +1,20 @@ +package com.dku.springstudy.service; + +import com.dku.springstudy.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +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; + +@RequiredArgsConstructor +@Service +public class CustomUserDetailService implements UserDetailsService { + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return memberRepository.findByEmail(username) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다")); + } +} diff --git a/src/main/java/com/dku/springstudy/service/MemberService.java b/src/main/java/com/dku/springstudy/service/MemberService.java new file mode 100644 index 0000000..3370dec --- /dev/null +++ b/src/main/java/com/dku/springstudy/service/MemberService.java @@ -0,0 +1,53 @@ +package com.dku.springstudy.service; + +import com.dku.springstudy.model.Member; +import com.dku.springstudy.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +// jpa를 통한 모든 데이터 변경은 트랜잭션 안에서 실행! +@Transactional +public class MemberService { + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final boolean LOGIN_SUCCESS = true; + private final boolean LOGIN_FAIL = false; + + public String join(Member newMember) { + validate(newMember); + memberRepository.save(newMember); + return newMember.getEmail(); + } + + private void validate(Member newMember) { + String newMemberEmail = newMember.getEmail(); + if (findOne(newMemberEmail).isPresent()) { + throw new IllegalArgumentException("이미 존재하는 이메일입니다"); + } + } + + public boolean login(String email, String rawPassword) { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("이메일을 다시 확인해주세요")); + + if (isMatchedPassword(member, rawPassword)) { + return LOGIN_SUCCESS; + } + return LOGIN_FAIL; + } + + private boolean isMatchedPassword(Member member, String rawPassword) { + // 스트링의 equals메서드로 하면 안됨. encode결과값이 그때그때 달라서리.. + return passwordEncoder.matches(rawPassword, member.getPassword()); + } + + public Optional findOne(String email) { + return memberRepository.findByEmail(email); + } +} diff --git a/src/main/java/com/dku/springstudy/util/SuccessResponseDTO.java b/src/main/java/com/dku/springstudy/util/SuccessResponseDTO.java new file mode 100644 index 0000000..6546b00 --- /dev/null +++ b/src/main/java/com/dku/springstudy/util/SuccessResponseDTO.java @@ -0,0 +1,17 @@ +package com.dku.springstudy.util; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.lang.Nullable; + +@AllArgsConstructor +@Data +public class SuccessResponseDTO { + private final boolean success; + private final T data; + + public SuccessResponseDTO(@Nullable T data) { + this.success = true; + this.data = data; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/test/java/com/dku/springstudy/jwt/JwtTokenProviderTest.java b/src/test/java/com/dku/springstudy/jwt/JwtTokenProviderTest.java new file mode 100644 index 0000000..d430648 --- /dev/null +++ b/src/test/java/com/dku/springstudy/jwt/JwtTokenProviderTest.java @@ -0,0 +1,67 @@ +package com.dku.springstudy.jwt; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class JwtTokenProviderTest { + @Autowired private JwtTokenProvider tokenProvider; + + @DisplayName("토큰 검증 테스트") + @Nested + class ValidateTest { + @DisplayName("토큰 검증 성공 테스트") + @Test + void successValidateToken() { + String secretKey = "1234560ACB6F1AD6B6A6184A31E6B7E37DB3818CC36871E26235DD67DCFE4041492"; + long expiredMs = 30 * 60 * 1000L; + String testToken = tokenProvider.createToken("test@naver.com", secretKey, expiredMs); + + assertThat(tokenProvider.validateToken(testToken, secretKey)).isTrue(); + } + + @DisplayName("토큰 검증 실패 - 생성/ 검증 시 사용하는 키가 다를 때") + @Test + void failValidateTokenByDifferentKey() { + String secretKey1 = "1234560ACB6F1AD6B6A6184A31E6B7E37DB3818CC36871E26235DD67DCFE4041492"; + String secretKey2 = "9234560ACB6F1AD6B6A6184A31E6B7E37DB3818CC36871E26235DD67DCFE4041492"; + long expiredMs = 30 * 60 * 1000L; + + String testToken = tokenProvider.createToken("test@naver.com", secretKey1, expiredMs); + + assertThat(tokenProvider.validateToken(testToken, secretKey2)).isFalse(); + } + + @DisplayName("토큰 검증 실패 - 유효기간만료") + @Test + void failValidateTokenByExpired() { + String secretKey = "1234560ACB6F1AD6B6A6184A31E6B7E37DB3818CC36871E26235DD67DCFE4041492"; + long expiredMs = 0L; + + String testToken = tokenProvider.createToken("test@naver.com", secretKey, expiredMs); + + assertThat(tokenProvider.validateToken(testToken, secretKey)).isFalse(); + } + } + + @DisplayName("토큰 확인 테스트") + @Nested + class AnalyzeTest { + @DisplayName("토큰에 저장된 이메일이 내가 적어준 이메일이 맞는지 테스트") + @Test + void getUserEmailFromToken() { + String secretKey = "1234560ACB6F1AD6B6A6184A31E6B7E37DB3818CC36871E26235DD67DCFE4041492"; + String testEmail = "test@naver.com"; + long expiredMs = 30 * 60 * 1000L; + String testToken = tokenProvider.createToken(testEmail, secretKey, expiredMs); + + assertThat(tokenProvider.getUserEmailFromToken(testToken, secretKey)).isEqualTo(testEmail); + } + } + +} diff --git a/src/test/java/com/dku/springstudy/service/MemberServiceIntegrationTest.java b/src/test/java/com/dku/springstudy/service/MemberServiceIntegrationTest.java new file mode 100644 index 0000000..946c486 --- /dev/null +++ b/src/test/java/com/dku/springstudy/service/MemberServiceIntegrationTest.java @@ -0,0 +1,113 @@ +package com.dku.springstudy.service; + +import com.dku.springstudy.model.Member; +import com.dku.springstudy.model.Role; +import com.dku.springstudy.repository.MemberRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@Transactional +class MemberServiceIntegrationTest { + @Autowired + MemberService memberService; + @Autowired + MemberRepository memberRepository; + @Autowired + PasswordEncoder passwordEncoder; + + @Nested + class SignupTest { + @DisplayName("회원가입 테스트") + @Test + void joinTest() { + Member member = new Member(); + member.setEmail("testets123@google.com"); + member.setPassword(passwordEncoder.encode("123123")); + member.setName("김김김"); + member.setPhone("01000121234"); + member.setNickname("testestrqe"); + member.setRole(Role.USER); + + String memberEmail = memberService.join(member); + + Member foundMember = memberRepository.findByEmail(memberEmail).get(); + System.out.println(foundMember.getEmail()); + System.out.println(memberEmail); + assertThat(foundMember.getEmail()).isEqualTo(memberEmail); + } + + @DisplayName("이메일 중복되면 오류가 난다") + @Test + void joinTestByExistingEmail() { + Member member = new Member(); + member.setEmail("testets123@google.com"); + member.setPassword(passwordEncoder.encode("123123")); + member.setName("김김김"); + member.setPhone("01000121234"); + member.setNickname("testestrqe"); + member.setRole(Role.USER); + memberService.join(member); + + Member member2 = new Member(); + member2.setEmail("testets123@google.com"); + member2.setPassword(passwordEncoder.encode("123123")); + member2.setName("박박박"); + member2.setPhone("01012345678"); + member2.setNickname("testsrqwqe"); + member2.setRole(Role.USER); + + assertThatThrownBy(() -> memberService.join(member2)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + class LoginTest { + @DisplayName("로그인 테스트") + @Test + void login() { + String email = "tt@naver.com"; + String rawPassword = "123123"; + Member member = new Member(); + member.setEmail(email); + member.setPassword(passwordEncoder.encode(rawPassword)); + member.setName("김김김"); + member.setPhone("01000121234"); + member.setNickname("testestrqe"); + member.setRole(Role.USER); + memberService.join(member); + + boolean result = memberService.login(email, rawPassword); + + assertThat(result).isTrue(); + } + + @DisplayName("비밀번호를 틀려서 로그인하면 실패한다") + @Test + void loginByNotExistingEmail() { + String email = "tt@naver.com"; + String rawPassword = "123123"; + Member member = new Member(); + member.setEmail(email); + member.setPassword(passwordEncoder.encode(rawPassword)); + member.setName("김김김"); + member.setPhone("01000121234"); + member.setNickname("testestrqe"); + member.setRole(Role.USER); + memberService.join(member); + + boolean result = memberService.login(email, "12312asdasd"); + + assertThat(result).isFalse(); + } + } +} \ No newline at end of file