diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 6415ed5..b633b0f 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -5,7 +5,7 @@ - + diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 5bf1db7..6674b96 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -1,11 +1,11 @@ - + mysql.8 true com.mysql.cj.jdbc.Driver - jdbc:mysql://localhost:3306/user?serverTimezone=UTC&characterEncoding=UTF-8 + jdbc:mysql://localhost:3305?play_ground $ProjectFileDir$ diff --git a/README.md b/README.md index 25feba4..d276ddb 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,12 @@ - https://console.cloud.google.com/ 개발자 센터 접속 - 프로젝트 생성 후 OAuth 2.0 클라이언트 Id 생성 - 승인된 리디렉션 URI: http://localhost:8080/login/oauth2/code/google path 고정 - - 클라이언트ID, 보안 비밀번호 yml 설정 추가 \ No newline at end of file + - 클라이언트ID, 보안 비밀번호 yml 설정 추가 + +### JWT +- JSON 객체를 사용해서 토큰 자체에 정보들을 저장하고 있는 Web Token +- 기존 세션 방식의 처리는 서버가 메모리 혹은 별도의 세션을 저장할 공간이 필요했음(하드디스크, DB) + - 서버의 부담 + - auto scale이 되어 서버 인스턴스가 늘어났을 때, 메모리를 서로 공유하지 못하기 때문에 세션을 공유할 수 없음. + - 중간에 서버가 죽으면? 메모리에 있던 정보들 다 날라감. 별도 저장소를 구성하기엔 비용이 듦 + - 이러한 이유 등으로 인해 jwt 웹 표준(RFC 7519) 등장. \ No newline at end of file diff --git a/build.gradle b/build.gradle index bf895e1..97e1240 100644 --- a/build.gradle +++ b/build.gradle @@ -24,15 +24,20 @@ repositories { 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-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-mustache' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - runtimeOnly 'com.mysql:mysql-connector-j:8.0.33' + + runtimeOnly 'com.h2database:h2' +// runtimeOnly 'com.mysql:mysql-connector-j:8.0.33' + + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..630bf30 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +## compose 파일 버전 +#version: '3' +#services: +# # 서비스 명 +# db: +# # 사용할 이미지 +# image: mysql +# # 컨테이너 실행 시 재시작 +# restart: always +# # 컨테이너명 설정 +# container_name: mysql-container-by-compose +# # 접근 포트 설정(컨테이너 외부:컨테이너 내부) +# ports: +# +# - 3307:3306 +# # 환경 변수 설정 +# environment: +# MYSQL_ROOT_PASSWORD: root +# # 명령어 설정 +# command: +# - --character-set-server=utf8mb4 +# - --collation-server=utf8mb4_unicode_ci +## volumes: +## - \ No newline at end of file diff --git a/src/main/java/com/example/security2/auth/PrincipalDetails.java b/src/main/java/com/example/security2/auth/PrincipalDetails.java deleted file mode 100644 index 4a6caad..0000000 --- a/src/main/java/com/example/security2/auth/PrincipalDetails.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.example.security2.auth; - -import com.example.security2.entity.User; -import lombok.Getter; -import lombok.Setter; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; - -import java.util.Collection; -import java.util.List; -import java.util.Map; - -/** - * 시큐리티가 /login 주소 요청이 오면 낚아채서 로그인을 진행시킨다. - * 로그인이 진행이 완료가 되면 시큐리티 session을 만들어준다.(SecurityContextHolder) - * 오브젝트 => Authentication 타입의 객체 - * Authentication 안에 User 정보가 있어야 됨. - * User 객체는 UserDetails 타입 객체(Security) - * - * Security Session => Authentication => UserDetails(PrincipalDetails) - */ -@Getter -@Setter -public class PrincipalDetails implements UserDetails, OAuth2User { - - // composition - private final User user; - private Map attributes; - - // 일반 로그인 - public PrincipalDetails(User user) { - this.user = user; - } - - // OAuth 로그인 - public PrincipalDetails(User user, Map attributes) { - this(user); - this.attributes = attributes; - } - - @Override - public Map getAttributes() { - return attributes; - } - - // 해당 User의 권한을 리턴하는 곳! - @Override - public Collection getAuthorities() { - return List.of((GrantedAuthority) user::getRole); - } - - @Override - public String getPassword() { - return user.getPassword(); - } - - @Override - public String getUsername() { - return user.getEmail(); - } - - // 계정이 만료되지 않은지(true: 만료되지 않음 / false: 만료됨) - @Override - public boolean isAccountNonExpired() { - return true; - } - - // 계정이 잠겨있지 않은지(true: 잠기지 않음 / false: 잠김) - @Override - public boolean isAccountNonLocked() { - return true; - } - - // 비밀번호가 만료되지 않은지(true: 만료안됨 / false: 만료) - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - // 계정이 활성화되어 있는지(true: 활성화 / false: 비활) - @Override - public boolean isEnabled() { - /** - * TODO - * 만일 우리 사이트의 정책이 1년동안 로그인하지 않은 회원을 휴면처리 하기로 했다면? - * 현재 서버시간 - 마지막 로그인 시간 => 1년을 초과하면 return false; - * System.currentTimeMillis() - user.getLastLoginDate(); - */ - - return true; - } - - @Override - public String getName() { - return (String) attributes.get("sub"); - } -} diff --git a/src/main/java/com/example/security2/auth/PrincipalDetailsService.java b/src/main/java/com/example/security2/auth/PrincipalDetailsService.java deleted file mode 100644 index aa1278f..0000000 --- a/src/main/java/com/example/security2/auth/PrincipalDetailsService.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.example.security2.auth; - -import com.example.security2.entity.User; -import com.example.security2.repository.UserRepository; -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; - -// 시큐리티 설정에서 .loginProcessingUrl("/login"); -// /login 요청이 오면 자동으로 UserDetailsService 타입으로 IOC 되어있는 loadUserByUsername 메서드가 실행 -@Service -@RequiredArgsConstructor -public class PrincipalDetailsService implements UserDetailsService { - - private final UserRepository userRepository; - - // 시큐리티 session(내부 Authentication(내부 UserDetails)) - // 함수 종료 시 @AuthenticationPrincipal 어노테이션이 만들어진다. - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - User foundUser = userRepository.findByEmail(username) - .stream() - .findFirst() - .orElseThrow(() -> new UsernameNotFoundException("존재하지 유저")); - - return new PrincipalDetails(foundUser); - } -} diff --git a/src/main/java/com/example/security2/config/CorsConfig.java b/src/main/java/com/example/security2/config/CorsConfig.java new file mode 100644 index 0000000..2bc5680 --- /dev/null +++ b/src/main/java/com/example/security2/config/CorsConfig.java @@ -0,0 +1,25 @@ +package com.example.security2.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +public class CorsConfig { + + @Bean + public CorsFilter corsFilter() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedOriginPattern("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + + source.registerCorsConfiguration("/rest/api/**", config); + return new CorsFilter(source); + } +} diff --git a/src/main/java/com/example/security2/config/JwtSecurityConfig.java b/src/main/java/com/example/security2/config/JwtSecurityConfig.java new file mode 100644 index 0000000..541882c --- /dev/null +++ b/src/main/java/com/example/security2/config/JwtSecurityConfig.java @@ -0,0 +1,25 @@ +package com.example.security2.config; + +import com.example.security2.jwt.JwtFilter; +import com.example.security2.jwt.TokenProvider; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +public class JwtSecurityConfig extends SecurityConfigurerAdapter { + + private final TokenProvider tokenProvider; + + public JwtSecurityConfig(TokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + @Override + public void configure(HttpSecurity http) throws Exception { + // security 로직에 JwtFilter 등록 + http.addFilterBefore( + new JwtFilter(tokenProvider), + UsernamePasswordAuthenticationFilter.class); + } +} diff --git a/src/main/java/com/example/security2/config/SecurityConfig.java b/src/main/java/com/example/security2/config/SecurityConfig.java index 1a40adb..a96af67 100644 --- a/src/main/java/com/example/security2/config/SecurityConfig.java +++ b/src/main/java/com/example/security2/config/SecurityConfig.java @@ -1,59 +1,75 @@ package com.example.security2.config; -import com.example.security2.oauth.PrincipalOauth2UserService; +import com.example.security2.jwt.JwtAccessDeniedHandler; +import com.example.security2.jwt.JwtAuthenticationEntryPoint; +import com.example.security2.jwt.TokenProvider; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.web.filter.CorsFilter; @Configuration -@EnableWebSecurity // 스프링 필터체인에 등록 -@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true) // 권한 처리, Secured 어노테이션 활성화. preAuthorize, postAuthorize 어노테이션 활성화 +@EnableWebSecurity +@EnableMethodSecurity public class SecurityConfig { - private final PrincipalOauth2UserService principalOauth2UserService; + private final CorsFilter corsFilter; + private final TokenProvider tokenProvider; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; - public SecurityConfig(PrincipalOauth2UserService principalOauth2UserService) { - this.principalOauth2UserService = principalOauth2UserService; + public SecurityConfig(CorsFilter corsFilter, TokenProvider tokenProvider, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtAccessDeniedHandler jwtAccessDeniedHandler) { + this.corsFilter = corsFilter; + this.tokenProvider = tokenProvider; + this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; + this.jwtAccessDeniedHandler = jwtAccessDeniedHandler; } - // SecurityConfig <-> PrincipalOauth2UserService 순환참조로 인해 분리(PasswordConfig) - /*@Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - }*/ - @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // csrf, cors 일단 비활성화 - http.csrf(AbstractHttpConfigurer::disable); - http.cors(AbstractHttpConfigurer::disable); + http + .csrf(AbstractHttpConfigurer::disable) +// .cors(AbstractHttpConfigurer::disable) - http.authorizeHttpRequests((requests) -> requests - .requestMatchers("/user/**").authenticated() - .requestMatchers("/manager/**").access( - new WebExpressionAuthorizationManager("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')") + .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class) + // jwt 예외핸들러 등록 + .exceptionHandling(httpSecurityExceptionHandlingConfigurer -> + httpSecurityExceptionHandlingConfigurer + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) ) - .requestMatchers("/admin/**").access( - new WebExpressionAuthorizationManager("hasRole('ROLE_ADMIN')") + + // 세션을 사용하지 않기 때문에 "STATELESS"로 설정 + .sessionManagement(sessionManagementConfigurer -> + sessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - .anyRequest().permitAll()) - .formLogin(form -> form - .loginPage("/loginForm") - .usernameParameter("email") - .loginProcessingUrl("/login") - .defaultSuccessUrl("/") + + .authorizeHttpRequests((requests) -> requests + .requestMatchers(PathRequest.toH2Console()).permitAll() + .requestMatchers( + new AntPathRequestMatcher("/api/v1/auth"), + new AntPathRequestMatcher("/api/v1/user/hello"), + new AntPathRequestMatcher("/api/v1/user/signup") + ).permitAll() + .anyRequest().authenticated() ) - .oauth2Login(oauth2 -> oauth2 - .loginPage("/loginForm") - .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig - .userService(principalOauth2UserService) - ) - ); + // enable h2 console + .headers(header -> header + .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin) + ) + + // JwtFilter 적용 + .apply(new JwtSecurityConfig(tokenProvider)); return http.build(); } diff --git a/src/main/java/com/example/security2/config/WebMvcConfig.java b/src/main/java/com/example/security2/config/WebMvcConfig.java deleted file mode 100644 index 2684341..0000000 --- a/src/main/java/com/example/security2/config/WebMvcConfig.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.security2.config; - -import org.springframework.boot.web.servlet.view.MustacheViewResolver; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.ViewResolverRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class WebMvcConfig implements WebMvcConfigurer { - - @Override - public void configureViewResolvers(ViewResolverRegistry registry) { - // 머스테치를 html로 재설정. - MustacheViewResolver viewResolver = new MustacheViewResolver(); - viewResolver.setCharset("UTF-8"); - viewResolver.setContentType("text/html;charset=UTF-8"); - viewResolver.setPrefix("classpath:/templates/"); - viewResolver.setSuffix(".html"); - - registry.viewResolver(viewResolver); - } -} diff --git a/src/main/java/com/example/security2/controller/AuthController.java b/src/main/java/com/example/security2/controller/AuthController.java new file mode 100644 index 0000000..6ed0086 --- /dev/null +++ b/src/main/java/com/example/security2/controller/AuthController.java @@ -0,0 +1,46 @@ +package com.example.security2.controller; + +import com.example.security2.dto.LoginDto; +import com.example.security2.dto.TokenDto; +import com.example.security2.jwt.JwtFilter; +import com.example.security2.jwt.TokenProvider; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +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("/api/v1/auth") +public class AuthController { + + private final TokenProvider tokenProvider; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + + public AuthController(TokenProvider tokenProvider, AuthenticationManagerBuilder authenticationManagerBuilder) { + this.tokenProvider = tokenProvider; + this.authenticationManagerBuilder = authenticationManagerBuilder; + } + + @PostMapping + public ResponseEntity authenticate(@Valid @RequestBody LoginDto loginDto) { + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword()); + + Authentication authenticate = authenticationManagerBuilder.getObject().authenticate(authenticationToken); + SecurityContextHolder.getContext().setAuthentication(authenticate); + String jwt = tokenProvider.createToken(authenticate); + + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt); + + return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK); + } +} diff --git a/src/main/java/com/example/security2/controller/HealthController.java b/src/main/java/com/example/security2/controller/HealthController.java deleted file mode 100644 index 89126aa..0000000 --- a/src/main/java/com/example/security2/controller/HealthController.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.security2.controller; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/health") -public class HealthController { - - @GetMapping - public String health() { - return "health"; - } -} diff --git a/src/main/java/com/example/security2/controller/UserController.java b/src/main/java/com/example/security2/controller/UserController.java new file mode 100644 index 0000000..72a2158 --- /dev/null +++ b/src/main/java/com/example/security2/controller/UserController.java @@ -0,0 +1,39 @@ +package com.example.security2.controller; + +import com.example.security2.dto.UserDto; +import com.example.security2.service.UserService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/user") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @GetMapping("/hello") + public ResponseEntity hello() { + return ResponseEntity.ok("hello"); + } + + @PostMapping("/signup") + public ResponseEntity signUp(@RequestBody UserDto userDto) { + return ResponseEntity.ok(userService.signUp(userDto)); + } + + @GetMapping + @PreAuthorize("hasAnyRole('USER','ADMIN')") + public ResponseEntity getMyUserInfo(HttpServletRequest request) { + return ResponseEntity.ok(userService.getMyUserWithAuthorities()); + } + + @GetMapping("/{username}") + @PreAuthorize("hasAnyRole('ADMIN')") + public ResponseEntity getUserInfo(@PathVariable String username) { + return ResponseEntity.ok(userService.getUserWithAuthorities(username)); + } +} diff --git a/src/main/java/com/example/security2/controller/ViewController.java b/src/main/java/com/example/security2/controller/ViewController.java deleted file mode 100644 index c35f0e5..0000000 --- a/src/main/java/com/example/security2/controller/ViewController.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.example.security2.controller; - -import com.example.security2.auth.PrincipalDetails; -import com.example.security2.entity.User; -import com.example.security2.enums.AuthType; -import com.example.security2.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.access.annotation.Secured; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.ResponseBody; - -/** - * View - */ -@Controller -@Slf4j -@RequiredArgsConstructor -public class ViewController { - - // 간편구현을 위해 레포지토리만 생성 - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - @GetMapping({"", "/"}) - public String index() { - return "index"; - } - - // @AuthenticationPrincipal 어노테이션으로 인증객체 내 User정보 확인 가능 - @GetMapping("/user") - public @ResponseBody String user(@AuthenticationPrincipal PrincipalDetails principalDetails) { - log.info("principalDetails: {}", principalDetails.getUser()); - return "user"; - } - - @GetMapping("/admin") - public @ResponseBody String admin() { - return "admin"; - } - - @GetMapping("/manager") - public @ResponseBody String manager() { - return "manager"; - } - - @GetMapping("/loginForm") - public String loginForm() { - return "loginForm"; - } - - @GetMapping("/joinForm") - public String joinForm() { - return "joinForm"; - } - - @PostMapping("/join") - public String join(User user) { - user.setRole(AuthType.ROLE_USER.name()); - user.setPassword(passwordEncoder.encode(user.getPassword())); - - userRepository.save(user); - return "redirect:/loginForm"; - } - - @Secured("ROLE_ADMIN") - @GetMapping("/admin-only") - public @ResponseBody String accessOnlyAdmin() { - return "어드민 권한만 접근 가능"; - } - - /** - * 권한 하나로만 걸고 싶으면 @Secured로 처리하기 쉽고, 여러개면 @PreAuthorize로 가능 - * - * @PreAuthorize 함수 실행 전 실행되는 어노테이션 - * @PostAuthorize 함수 실행 후 실행되는 어노테이션 -> 후처리는 잘 사용하지 않음 - */ - @PreAuthorize("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')") - @GetMapping("/manager-and-admin") - public @ResponseBody String accessManagerAndAdmin() { - return "매니저, 관리자 권한만 접근 허용"; - } -} diff --git a/src/main/java/com/example/security2/dto/AuthorityDto.java b/src/main/java/com/example/security2/dto/AuthorityDto.java new file mode 100644 index 0000000..42b5beb --- /dev/null +++ b/src/main/java/com/example/security2/dto/AuthorityDto.java @@ -0,0 +1,4 @@ +package com.example.security2.dto; + +public record AuthorityDto(String authorityName) { +} diff --git a/src/main/java/com/example/security2/dto/ErrorDto.java b/src/main/java/com/example/security2/dto/ErrorDto.java new file mode 100644 index 0000000..271faf1 --- /dev/null +++ b/src/main/java/com/example/security2/dto/ErrorDto.java @@ -0,0 +1,18 @@ +package com.example.security2.dto; + +import org.springframework.validation.FieldError; + +import java.util.ArrayList; +import java.util.List; + +public record ErrorDto(int status, String message, List fieldErrors) { + + public ErrorDto(int status, String message) { + this(status, message, new ArrayList<>()); + } + + public void addFieldError(String objectName, String path, String message) { + FieldError error = new FieldError(objectName, path, message); + fieldErrors.add(error); + } +} diff --git a/src/main/java/com/example/security2/dto/LoginDto.java b/src/main/java/com/example/security2/dto/LoginDto.java new file mode 100644 index 0000000..ce6a047 --- /dev/null +++ b/src/main/java/com/example/security2/dto/LoginDto.java @@ -0,0 +1,21 @@ +package com.example.security2.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class LoginDto { + + @NotNull + @Size(min = 3, max = 50) + private String username; + + @NotNull + @Size(min = 3, max = 100) + private String password; +} diff --git a/src/main/java/com/example/security2/dto/TokenDto.java b/src/main/java/com/example/security2/dto/TokenDto.java new file mode 100644 index 0000000..f3bebaa --- /dev/null +++ b/src/main/java/com/example/security2/dto/TokenDto.java @@ -0,0 +1,4 @@ +package com.example.security2.dto; + +public record TokenDto(String token) { +} diff --git a/src/main/java/com/example/security2/dto/UserDto.java b/src/main/java/com/example/security2/dto/UserDto.java new file mode 100644 index 0000000..1359523 --- /dev/null +++ b/src/main/java/com/example/security2/dto/UserDto.java @@ -0,0 +1,47 @@ +package com.example.security2.dto; + +import com.example.security2.entity.Authority; +import com.example.security2.entity.User; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +import java.util.Set; +import java.util.stream.Collectors; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class UserDto { + + @NotNull + @Size(min = 3, max = 50) + private String username; + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @NotNull + @Size(min = 3, max = 100) + private String password; + + @NotNull + @Size(min = 3, max = 50) + private String nickname; + + private Set authorityDtoSet; + + public static UserDto from(User user) { + if (user == null) + return null; + + return UserDto.builder() + .username(user.getUsername()) + .nickname(user.getNickname()) + .authorityDtoSet(user.getAuthorities().stream() + .map(authority -> new AuthorityDto(authority.getAuthorityName())) + .collect(Collectors.toSet())) + .build(); + } +} diff --git a/src/main/java/com/example/security2/entity/Authority.java b/src/main/java/com/example/security2/entity/Authority.java new file mode 100644 index 0000000..f80574e --- /dev/null +++ b/src/main/java/com/example/security2/entity/Authority.java @@ -0,0 +1,22 @@ +package com.example.security2.entity; + +import lombok.*; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "authority") +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Authority { + + @Id + @Column(name = "authority_name", length = 50) + private String authorityName; +} \ No newline at end of file diff --git a/src/main/java/com/example/security2/entity/User.java b/src/main/java/com/example/security2/entity/User.java index 3e65117..22fe5ba 100644 --- a/src/main/java/com/example/security2/entity/User.java +++ b/src/main/java/com/example/security2/entity/User.java @@ -3,48 +3,46 @@ import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; +import java.util.Set; @Entity @Getter @Setter -@Table(name = "users") +@Table(name = "`user`") +@Builder +@AllArgsConstructor @NoArgsConstructor -@ToString public class User { @Id + @Column(name = "user_id") @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + private Long userId; - @Column(nullable = false, unique = true) - private String email; + @Column(length = 50, unique = true) + private String username; - @Column(nullable = false) + @Column(length = 100) private String password; - private String name; + @Column(length = 50) + private String nickname; - private Integer age; - - private String role; - - private String provider; - private String providerId; + private boolean activated; @CreationTimestamp private LocalDateTime createDate; -// private LocalDateTime lastLoginDate; - - - @Builder - public User(String email, String password, String name, String role, String provider, String providerId) { - this.email = email; - this.password = password; - this.name = name; - this.role = role; - this.provider = provider; - this.providerId = providerId; - } + + @UpdateTimestamp + private LocalDateTime updateDate; + + @ManyToMany + @JoinTable( + name = "user_authority", + joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "user_id")}, + inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "authority_name")}) + private Set authorities; } diff --git a/src/main/java/com/example/security2/enums/AuthType.java b/src/main/java/com/example/security2/enums/AuthType.java deleted file mode 100644 index f29fe93..0000000 --- a/src/main/java/com/example/security2/enums/AuthType.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.security2.enums; - -public enum AuthType { - - ROLE_ADMIN, - ROLE_MANAGER, - ROLE_USER; -} diff --git a/src/main/java/com/example/security2/exception/DuplicateMemberException.java b/src/main/java/com/example/security2/exception/DuplicateMemberException.java new file mode 100644 index 0000000..27154cc --- /dev/null +++ b/src/main/java/com/example/security2/exception/DuplicateMemberException.java @@ -0,0 +1,20 @@ +package com.example.security2.exception; + +public class DuplicateMemberException extends RuntimeException { + + public DuplicateMemberException() { + super(); + } + + public DuplicateMemberException(String message, Throwable cause) { + super(message, cause); + } + + public DuplicateMemberException(String message) { + super(message); + } + + public DuplicateMemberException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/example/security2/exception/NotFoundMemberException.java b/src/main/java/com/example/security2/exception/NotFoundMemberException.java new file mode 100644 index 0000000..a1106a5 --- /dev/null +++ b/src/main/java/com/example/security2/exception/NotFoundMemberException.java @@ -0,0 +1,20 @@ +package com.example.security2.exception; + +public class NotFoundMemberException extends RuntimeException { + + public NotFoundMemberException() { + super(); + } + + public NotFoundMemberException(String message, Throwable cause) { + super(message, cause); + } + + public NotFoundMemberException(String message) { + super(message); + } + + public NotFoundMemberException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/example/security2/handler/MethodArgumentNotValidExceptionHandler.java b/src/main/java/com/example/security2/handler/MethodArgumentNotValidExceptionHandler.java new file mode 100644 index 0000000..c5a2fde --- /dev/null +++ b/src/main/java/com/example/security2/handler/MethodArgumentNotValidExceptionHandler.java @@ -0,0 +1,38 @@ +package com.example.security2.handler; + +import com.example.security2.dto.ErrorDto; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +import java.util.List; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +@Order(Ordered.HIGHEST_PRECEDENCE) +@ControllerAdvice +public class MethodArgumentNotValidExceptionHandler { + + @ResponseStatus(BAD_REQUEST) + @ResponseBody + @ExceptionHandler(MethodArgumentNotValidException.class) + public ErrorDto methodArgumentNotValidException(MethodArgumentNotValidException ex) { + BindingResult result = ex.getBindingResult(); + List fieldErrors = result.getFieldErrors(); + return processFieldErrors(fieldErrors); + } + + private ErrorDto processFieldErrors(List fieldErrors) { + ErrorDto errorDto = new ErrorDto(BAD_REQUEST.value(), "@Valid Error"); + for (FieldError fieldError : fieldErrors) { + errorDto.addFieldError(fieldError.getObjectName(), fieldError.getField(), fieldError.getDefaultMessage()); + } + return errorDto; + } +} diff --git a/src/main/java/com/example/security2/handler/RestResponseExceptionHandler.java b/src/main/java/com/example/security2/handler/RestResponseExceptionHandler.java new file mode 100644 index 0000000..14385f3 --- /dev/null +++ b/src/main/java/com/example/security2/handler/RestResponseExceptionHandler.java @@ -0,0 +1,29 @@ +package com.example.security2.handler; + +import com.example.security2.dto.ErrorDto; +import com.example.security2.exception.DuplicateMemberException; +import com.example.security2.exception.NotFoundMemberException; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import java.nio.file.AccessDeniedException; + +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.FORBIDDEN; + +@RestControllerAdvice +public class RestResponseExceptionHandler extends ResponseEntityExceptionHandler { + + @ResponseStatus(CONFLICT) + @ExceptionHandler(value = { DuplicateMemberException.class }) + protected ErrorDto conflict(RuntimeException ex, WebRequest request) { + return new ErrorDto(CONFLICT.value(), ex.getMessage()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(value = { NotFoundMemberException.class, AccessDeniedException.class }) + protected ErrorDto forbidden(RuntimeException ex, WebRequest request) { + return new ErrorDto(FORBIDDEN.value(), ex.getMessage()); + } +} diff --git a/src/main/java/com/example/security2/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/example/security2/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..393d827 --- /dev/null +++ b/src/main/java/com/example/security2/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,22 @@ +package com.example.security2.jwt; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + // 필요한 권한이 없이 접근하려 할때 403 + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } +} diff --git a/src/main/java/com/example/security2/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/example/security2/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..c03f5c4 --- /dev/null +++ b/src/main/java/com/example/security2/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,23 @@ +package com.example.security2.jwt; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + // 유효한 자격증명을 제공하지 않고 접근하려 할 때 401 권한에러 + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } +} diff --git a/src/main/java/com/example/security2/jwt/JwtFilter.java b/src/main/java/com/example/security2/jwt/JwtFilter.java new file mode 100644 index 0000000..cf5b3ff --- /dev/null +++ b/src/main/java/com/example/security2/jwt/JwtFilter.java @@ -0,0 +1,55 @@ +package com.example.security2.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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; + +public class JwtFilter extends GenericFilterBean { + + private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class); + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String BEARER_AUTH = "Bearer "; + private final TokenProvider tokenProvider; + + public JwtFilter(TokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + // 실제 필터링 로직 + // 토큰의 인증정보를 SecurityContext에 저장하는 역할 수행 + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletRequest httpServletRequest = (HttpServletRequest) request; + String jwt = resolveToken(httpServletRequest); + String requestURI = httpServletRequest.getRequestURI(); + + if (StringUtils.hasText(jwt)) { + Authentication authentication = tokenProvider.getAuthentication(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다. uri: {}", authentication, requestURI); + } else { + logger.debug("유효한 JWT 토큰이 없습니다. uri: {}", requestURI); + } + + chain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + return isBearerToken(bearerToken) ? bearerToken.substring(7) : null; + } + + private boolean isBearerToken(String token) { + return (StringUtils.hasText(token) && token.startsWith(BEARER_AUTH)); + } +} diff --git a/src/main/java/com/example/security2/jwt/TokenProvider.java b/src/main/java/com/example/security2/jwt/TokenProvider.java new file mode 100644 index 0000000..46fd206 --- /dev/null +++ b/src/main/java/com/example/security2/jwt/TokenProvider.java @@ -0,0 +1,92 @@ +package com.example.security2.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SecurityException; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +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; + +/** + * JWT를 생성하고 검증 + */ +@Component +@Slf4j +public class TokenProvider { + + private static final Logger logger = LoggerFactory.getLogger(TokenProvider.class); + private static final String AUTHORITIES_KEY = "secret"; + private final long tokenValidityInMilliseconds; + private final Key key; + + public TokenProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.token-validity-in-seconds}") long tokenValidityInMilliseconds) { + this.tokenValidityInMilliseconds = tokenValidityInMilliseconds * 1000; + this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); + } + + public String createToken(Authentication authentication) { + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + // 토큰의 expire 시간을 설정 + long now = new Date().getTime(); + Date validity = new Date(now + this.tokenValidityInMilliseconds); + + return Jwts.builder() + .setSubject(authentication.getName()) + .claim(AUTHORITIES_KEY, authorities) + .signWith(key, SignatureAlgorithm.HS512) + .setExpiration(validity) + .compact(); + } + + // 토큰으로 클레임을 만들고 이를 이용해 유저 객체를 만들어서 최종적으로 authentication 객체를 리턴 + public Authentication getAuthentication(String token) { + Claims claims = getTokenClaims(token); + + Collection authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) + .map(SimpleGrantedAuthority::new) + .toList(); + + User principal = new User(claims.getSubject(), "", authorities); + + return new UsernamePasswordAuthenticationToken(principal, token, authorities); + } + + // jwt 토큰 리턴 + private Claims getTokenClaims(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (SecurityException | MalformedJwtException e) { + throw new JwtException("잘못된 JWT 서명입니다."); + } catch (ExpiredJwtException e) { + throw new JwtException("만료된 JWT 토큰입니다."); + } catch (UnsupportedJwtException e) { + throw new JwtException("지원하지않는 JWT 토큰입니다."); + } catch (IllegalArgumentException e) { + throw new JwtException("JWT 토큰이 잘못되없습니다."); + } + } + +} diff --git a/src/main/java/com/example/security2/oauth/PrincipalOauth2UserService.java b/src/main/java/com/example/security2/oauth/PrincipalOauth2UserService.java deleted file mode 100644 index 6e28197..0000000 --- a/src/main/java/com/example/security2/oauth/PrincipalOauth2UserService.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.example.security2.oauth; - -import com.example.security2.auth.PrincipalDetails; -import com.example.security2.entity.User; -import com.example.security2.enums.AuthType; -import com.example.security2.repository.UserRepository; -import lombok.extern.slf4j.Slf4j; -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.OAuth2User; -import org.springframework.stereotype.Service; - -import java.util.Map; -import java.util.Optional; -import java.util.Random; - -@Service -@Slf4j -public class PrincipalOauth2UserService extends DefaultOAuth2UserService { - - private final PasswordEncoder passwordEncoder; - private final UserRepository userRepository; - - public PrincipalOauth2UserService(PasswordEncoder passwordEncoder, UserRepository userRepository) { - this.passwordEncoder = passwordEncoder; - this.userRepository = userRepository; - } - - // 함수 종료 시 @AuthenticationPrincipal 어노테이션이 만들어진다. - // 구글로 부터 받은 userRequest 데이터에 대한 후처리되는 함수. - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - OAuth2User oAuth2User = super.loadUser(userRequest); - Map attributes = oAuth2User.getAttributes(); - log.info("attr: {}", attributes.toString()); - - String email = oAuth2User.getAttribute("email"); - Optional optionalUser = userRepository.findByEmail(email); - - User userEntity; - if (optionalUser.isEmpty()) { - userEntity = createUserEntityByOAuth2User(userRequest, oAuth2User); - userRepository.save(userEntity); - } else { - userEntity = optionalUser.get(); - } - - return new PrincipalDetails(userEntity, oAuth2User.getAttributes()); - } - - private User createUserEntityByOAuth2User(OAuth2UserRequest userRequest, OAuth2User oAuth2User) { - String provider = userRequest.getClientRegistration().getRegistrationId(); // google - String providerId = oAuth2User.getAttribute("sub"); - String userName = String.format("%s_%s", provider, providerId); - String password = passwordEncoder.encode(randomPassword()); - String email = oAuth2User.getAttribute("email"); - String role = AuthType.ROLE_USER.name(); - - return User.builder() - .email(email) - .name(userName) - .password(password) - .role(role) - .provider(provider) - .providerId(providerId) - .build(); - } - - private String randomPassword() { - // 랜덤 비밀번호(0 ~ z까지 20자리 랜덤 비밀번호 생성) - Random random = new Random(); - int leftLimit = 97; // letter '0' - int rightLimit = 122; // letter 'z' - int targetStringLength = 20; - - return random.ints(leftLimit, rightLimit + 1) - .limit(targetStringLength) - .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) - .toString(); - } -} diff --git a/src/main/java/com/example/security2/repository/AuthorityRepository.java b/src/main/java/com/example/security2/repository/AuthorityRepository.java new file mode 100644 index 0000000..00e3176 --- /dev/null +++ b/src/main/java/com/example/security2/repository/AuthorityRepository.java @@ -0,0 +1,7 @@ +package com.example.security2.repository; + +import com.example.security2.entity.Authority; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AuthorityRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/security2/repository/UserRepository.java b/src/main/java/com/example/security2/repository/UserRepository.java index 862c00e..e845ec7 100644 --- a/src/main/java/com/example/security2/repository/UserRepository.java +++ b/src/main/java/com/example/security2/repository/UserRepository.java @@ -1,10 +1,13 @@ package com.example.security2.repository; import com.example.security2.entity.User; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface UserRepository extends JpaRepository { - Optional findByEmail(String email); + + @EntityGraph(attributePaths = "authorities") + Optional findByUsername(String username); } diff --git a/src/main/java/com/example/security2/service/CustomUserDetailService.java b/src/main/java/com/example/security2/service/CustomUserDetailService.java new file mode 100644 index 0000000..af984e9 --- /dev/null +++ b/src/main/java/com/example/security2/service/CustomUserDetailService.java @@ -0,0 +1,46 @@ +package com.example.security2.service; + +import com.example.security2.entity.User; +import com.example.security2.repository.UserRepository; +import jakarta.transaction.Transactional; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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.Component; + +import java.util.Collection; + +@Component("userDetailsService") +public class CustomUserDetailService implements UserDetailsService { + + private final UserRepository userRepository; + + public CustomUserDetailService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + @Transactional + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return userRepository.findByUsername(username) + .map(user -> createUser(username, user)) + .orElseThrow(() -> new UsernameNotFoundException(username + " > DB에서 찾을 수 없음.")); + } + + private org.springframework.security.core.userdetails.User createUser(String username, User user) { + if (!user.isActivated()) + throw new RuntimeException(username + " > 활성화된 유저가 아닙니다."); + + Collection grantedAuthorities = user.getAuthorities().stream() + .map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName())) + .toList(); + + return new org.springframework.security.core.userdetails.User( + user.getUsername(), + user.getPassword(), + grantedAuthorities + ); + } +} diff --git a/src/main/java/com/example/security2/service/UserService.java b/src/main/java/com/example/security2/service/UserService.java new file mode 100644 index 0000000..0e52243 --- /dev/null +++ b/src/main/java/com/example/security2/service/UserService.java @@ -0,0 +1,62 @@ +package com.example.security2.service; + +import com.example.security2.dto.UserDto; +import com.example.security2.entity.Authority; +import com.example.security2.entity.User; +import com.example.security2.exception.DuplicateMemberException; +import com.example.security2.exception.NotFoundMemberException; +import com.example.security2.repository.UserRepository; +import com.example.security2.util.SecurityUtil; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; + +@Service +public class UserService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @Transactional + public UserDto signUp(UserDto userDto) { + if (userRepository.findByUsername(userDto.getUsername()).orElse(null) != null) + throw new DuplicateMemberException("이미 가입되어 있는 유저"); + + Authority authority = Authority.builder() + .authorityName("ROLE_USER") + .build(); + + User user = User.builder() + .username(userDto.getUsername()) + .password(passwordEncoder.encode(userDto.getPassword())) + .nickname(userDto.getNickname()) + .authorities(Collections.singleton(authority)) + .activated(true) + .build(); + + return UserDto.from(userRepository.save(user)); + } + + @Transactional(readOnly = true) + public UserDto getMyUserWithAuthorities() { + /*String findUsername = SecurityUtil.getCurrentUsername().stream().findFirst().orElseThrow(() -> new UsernameNotFoundException("Member not found")); + User user = userRepository.findByUsername(findUsername).orElseThrow(() -> new NotFoundMemberException("Member not found"));*/ + + return UserDto.from( + SecurityUtil.getCurrentUsername() + .flatMap(userRepository::findByUsername) + .orElseThrow(() -> new NotFoundMemberException("Member not found")) + ); + } + + @Transactional(readOnly = true) + public UserDto getUserWithAuthorities(String username) { + return UserDto.from(userRepository.findByUsername(username).orElse(null)); + } +} diff --git a/src/main/java/com/example/security2/util/SecurityUtil.java b/src/main/java/com/example/security2/util/SecurityUtil.java new file mode 100644 index 0000000..ee3abd8 --- /dev/null +++ b/src/main/java/com/example/security2/util/SecurityUtil.java @@ -0,0 +1,32 @@ +package com.example.security2.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Optional; + +@Slf4j +public class SecurityUtil { + + private SecurityUtil() {} + + public static Optional getCurrentUsername() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null) { + log.debug("Security Context에 인증 정보가 없습니다."); + return Optional.empty(); + } + + String username; + if (authentication.getPrincipal() instanceof UserDetails springSecurityUser) { + username = springSecurityUser.getUsername(); + } else { + username = (String) authentication.getPrincipal(); + } + + return Optional.ofNullable(username); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 069b94e..3cfe1e4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,34 +1,33 @@ spring: - security: - oauth2: - client: - registration: - google: - client-id: 574898903244-qrjp11vrbl3dh7alaneqman8odgao4fb.apps.googleusercontent.com - client-secret: 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 - scope: - - email - - profile -# - openid -# facebook: + + h2: + console: + enabled: true datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - username: root - password: root - url: jdbc:mysql://localhost:3306/user?serverTimezone=UTC&characterEncoding=UTF-8 + url: jdbc:h2:~/test +# url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: jpa: - open-in-view: true + database-platform: org.hibernate.dialect.H2Dialect hibernate: - ddl-auto: update - naming: - physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl - show-sql: true + ddl-auto: create-drop properties: hibernate: format_sql: true + show_sql: true + defer-datasource-initialization: true + +jwt: + header: Authorization + #HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다. + #echo 'jvmoverloads-spring-boot-jwt-tutorial-secret-jvmoverloads-spring-boot-jwt-tutorial-secret'| base64 + secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK + token-validity-in-seconds: 86400 logging: level: - org.hibernate.sql: debug \ No newline at end of file + com.example: DEBUG \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..fd138b2 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,9 @@ +insert into "user" (username, password, nickname, activated, create_date, update_date) values ('admin', '$2a$08$lDnHPz7eUkSi6ao14Twuau08mzhWrL4kyZGGU5xfiGALO/Vxd5DOi', 'admin', 1, current_time, null); +insert into "user" (username, password, nickname, activated, create_date, update_date) values ('user', '$2a$08$UkVvwpULis18S19S5pZFn.YHPZt3oaqHZnDwqbCW9pft6uFtkXKDC', 'user', 1, current_time, null); + +insert into authority (authority_name) values ('ROLE_USER'); +insert into authority (authority_name) values ('ROLE_ADMIN'); + +insert into user_authority (user_id, authority_name) values (1, 'ROLE_USER'); +insert into user_authority (user_id, authority_name) values (1, 'ROLE_ADMIN'); +insert into user_authority (user_id, authority_name) values (2, 'ROLE_USER'); \ No newline at end of file diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html deleted file mode 100644 index 5a2cc2e..0000000 --- a/src/main/resources/templates/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - 인덱스 페이지 - - -

인덱스 페이지입니다.

- - \ No newline at end of file diff --git a/src/main/resources/templates/joinForm.html b/src/main/resources/templates/joinForm.html deleted file mode 100644 index f06b897..0000000 --- a/src/main/resources/templates/joinForm.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - 회원가입 페이지 - - -

회원가입 페이지

-
-
-
-
- -
- - - \ No newline at end of file diff --git a/src/main/resources/templates/loginForm.html b/src/main/resources/templates/loginForm.html deleted file mode 100644 index 4e18845..0000000 --- a/src/main/resources/templates/loginForm.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - 로그인 페이지 - - -

로그인 페이지

-
-
-
- -
- - -구글 로그인 -회원가입 - - - \ No newline at end of file