diff --git a/classmate/build.gradle b/classmate/build.gradle index 4d59ab4..56544cd 100644 --- a/classmate/build.gradle +++ b/classmate/build.gradle @@ -21,7 +21,10 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'com.auth0:java-jwt:4.2.1' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/classmate/src/main/java/devteamOne/classmate/ClassmateApplication.java b/classmate/src/main/java/devteamOne/classmate/ClassmateApplication.java index cb0d34f..06ce95b 100644 --- a/classmate/src/main/java/devteamOne/classmate/ClassmateApplication.java +++ b/classmate/src/main/java/devteamOne/classmate/ClassmateApplication.java @@ -2,7 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; @EnableJpaAuditing @SpringBootApplication @@ -11,5 +14,4 @@ public class ClassmateApplication { public static void main(String[] args) { SpringApplication.run(ClassmateApplication.class, args); } - } diff --git a/classmate/src/main/java/devteamOne/classmate/global/config/SecurityConfig.java b/classmate/src/main/java/devteamOne/classmate/global/config/SecurityConfig.java new file mode 100644 index 0000000..4e4fb40 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/config/SecurityConfig.java @@ -0,0 +1,110 @@ +package devteamOne.classmate.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import devteamOne.classmate.global.jwt.JwtService; +import devteamOne.classmate.global.jwt.filter.JwtAuthenticationProcessingFilter; +import devteamOne.classmate.global.login.filter.CustomJsonAuthenticationFilter; +import devteamOne.classmate.global.login.handler.LoginFailureHandler; +import devteamOne.classmate.global.login.handler.LoginSuccessHandler; +import devteamOne.classmate.global.login.service.LoginService; +import devteamOne.classmate.global.oauth.handler.OAuth2LoginFailureHandler; +import devteamOne.classmate.global.oauth.handler.OAuth2LoginSuccessHandler; +import devteamOne.classmate.global.oauth.service.CustomOAuth2UserService; +import devteamOne.classmate.user.repository.UserRepository; +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.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +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.logout.LogoutFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final ObjectMapper objectMapper; + private final UserRepository userRepository; + private final LoginService loginService; + private final JwtService jwtService; + + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler; + private final CustomOAuth2UserService customOAuth2UserService; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .formLogin().disable() + .httpBasic().disable() + .csrf().disable() + .headers().frameOptions().disable() + .and() + + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + + .authorizeRequests() + .requestMatchers("/sign-up").permitAll() + .anyRequest().authenticated() + + .and() + .oauth2Login() + .successHandler(oAuth2LoginSuccessHandler) + .failureHandler(oAuth2LoginFailureHandler) + .userInfoEndpoint().userService(customOAuth2UserService); + + http.addFilterAfter(customJsonAuthenticationFilter(), LogoutFilter.class); + http.addFilterBefore(jwtAuthenticationProcessingFilter(), CustomJsonAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setPasswordEncoder(passwordEncoder()); + provider.setUserDetailsService(loginService); + return new ProviderManager(provider); + } + + @Bean + public LoginSuccessHandler loginSuccessHandler() { + return new LoginSuccessHandler(jwtService, userRepository); + } + + @Bean + public LoginFailureHandler loginFailureHandler() { + return new LoginFailureHandler(); + } + + + @Bean + public CustomJsonAuthenticationFilter customJsonAuthenticationFilter() { + CustomJsonAuthenticationFilter customJsonAuthenticationFilter = + new CustomJsonAuthenticationFilter(objectMapper); + + customJsonAuthenticationFilter.setAuthenticationManager(authenticationManager()); + customJsonAuthenticationFilter.setAuthenticationSuccessHandler(loginSuccessHandler()); + customJsonAuthenticationFilter.setAuthenticationFailureHandler(loginFailureHandler()); + + return customJsonAuthenticationFilter; + } + + @Bean + public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() { + return new JwtAuthenticationProcessingFilter(jwtService, userRepository); + } +} \ No newline at end of file diff --git a/classmate/src/main/java/devteamOne/classmate/global/jwt/JwtService.java b/classmate/src/main/java/devteamOne/classmate/global/jwt/JwtService.java new file mode 100644 index 0000000..1c7abae --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/jwt/JwtService.java @@ -0,0 +1,148 @@ +package devteamOne.classmate.global.jwt; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import devteamOne.classmate.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.transaction.Transactional; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Getter +@Slf4j +public class JwtService { + + @Value("${jwt.secretKey}") + private String secretKey; + + @Value("${jwt.access.expiration}") + private Long accessTokenExpirationPeriod; + + @Value("${jwt.refresh.expiration}") + private Long refreshTokenExpirationPeriod; + + @Value("${jwt.access.header}") + private String accessHeader; + + @Value("${jwt.refresh.header}") + private String refreshHeader; + + private static final String ACCESS_TOKEN_SUBJECT = "AccessToken"; + private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; + private static final String EMAIL_CLAIM = "email"; + private static final String BEARER = "Bearer "; + + private final UserRepository userRepository; + + /** + * AccessToken 생성 메서드 + */ + public String createAccessToken(String email) { + Date now = new Date(); + + return JWT.create() + .withSubject(ACCESS_TOKEN_SUBJECT) + .withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod)) + + .withClaim(EMAIL_CLAIM, email) + .sign(Algorithm.HMAC512(secretKey)); + } + + /** + * RefreshToken 생성 메서드 + */ + public String createRefreshToken() { + Date now = new Date(); + + return JWT.create() + .withSubject(REFRESH_TOKEN_SUBJECT) + .withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod)) + .sign(Algorithm.HMAC512(secretKey)); + } + + /** + * 응답 메시지에 AccessToken 헤더에 실어서 보내기 + */ + public void sendAccessToken(HttpServletResponse response, String accessToken) { + + response.setStatus(HttpServletResponse.SC_OK); + response.setHeader(accessHeader, accessToken); + log.info("재발급된 Access Token : {}", accessToken); + } + + /** + * 응답 메시지에 AccessToken + RefreshToken 헤더에 실어서 보내기 + */ + public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) { + response.setStatus(HttpServletResponse.SC_OK); + + response.setHeader(accessHeader, accessToken); + response.setHeader(refreshHeader, refreshToken); + log.info("Access Token, Refresh Token 헤더 설정 완료"); + } + + /** + * 헤더에서 AccessToken 추출 + */ + public Optional extractAccessToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(accessHeader)) + .filter(refreshToken -> refreshToken.startsWith(BEARER)) + .map(refreshToken -> refreshToken.replace(BEARER, "")); + } + + /** + * 헤더에서 RefreshToken 추출 + */ + public Optional extractRefreshToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(refreshHeader)) + .filter(refreshToken -> refreshToken.startsWith(BEARER)) + .map(refreshToken -> refreshToken.replace(BEARER, "")); + } + + /** + * AccessToken 에서 Email 추출 + */ + public Optional extractEmail(String accessToken) { + try { + return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey)) + .build() + .verify(accessToken) + .getClaim(EMAIL_CLAIM) + .asString()); + } catch (Exception e) { + log.error("액세스 토큰이 유효하지 않습니다."); + return Optional.empty(); + } + } + + /** + * RefreshToken DB 업데이트 + */ + @Transactional + public void updateRefreshToken(String email, String refreshToken) { + userRepository.findByEmail(email) + .ifPresentOrElse( + user -> user.updateRefreshToken(refreshToken), + () -> new Exception("일치하는 회원이 없습니다.") + ); + } + + public boolean isTokenValid(String token) { + try { + JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token); + return true; + } catch (Exception e) { + log.error("유효하지 않은 토큰입니다. {}", e.getMessage()); + return false; + } + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/global/jwt/filter/JwtAuthenticationProcessingFilter.java b/classmate/src/main/java/devteamOne/classmate/global/jwt/filter/JwtAuthenticationProcessingFilter.java new file mode 100644 index 0000000..0cc46d9 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/jwt/filter/JwtAuthenticationProcessingFilter.java @@ -0,0 +1,105 @@ +package devteamOne.classmate.global.jwt.filter; + +import devteamOne.classmate.global.jwt.JwtService; +import devteamOne.classmate.global.jwt.util.PasswordUtil; +import devteamOne.classmate.user.domain.User; +import devteamOne.classmate.user.repository.UserRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; +import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter { + + private static final String NO_CHECK_URL = "/login"; + + private final JwtService jwtService; + private final UserRepository userRepository; + + private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + if (request.getRequestURI().equals(NO_CHECK_URL)) { + filterChain.doFilter(request, response); + return; + } + + String refreshToken = jwtService.extractRefreshToken(request) + .filter(jwtService::isTokenValid) + .orElse(null); + + if (refreshToken != null) { + checkRefreshTokenAndReIssueAccessToken(response, refreshToken); + return; + } + + if (refreshToken == null) { + checkAccessTokenAndAuthentication(request, response, filterChain); + } + + } + + private void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + log.info("call checkAccessTokenAndAuthentication()"); + + jwtService.extractAccessToken(request) + .filter(jwtService::isTokenValid) + .ifPresent(accessToken -> jwtService.extractEmail(accessToken) + .ifPresent(email -> userRepository.findByEmail(email) + .ifPresent(this::saveAuthentication))); + + filterChain.doFilter(request, response); + } + + private void saveAuthentication(User user) { + String password = user.getPassword(); + + if (password == null) { + password = PasswordUtil.generateRandomPassword(); + } + + UserDetails userDetails = org.springframework.security.core.userdetails.User.builder() + .username(user.getEmail()) + .password(password) + .roles(user.getRole().name()) + .build(); + + Authentication authentication = + new UsernamePasswordAuthenticationToken(userDetails, null + , authoritiesMapper.mapAuthorities(userDetails.getAuthorities())); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) { + userRepository.findByRefreshToken(refreshToken) + .ifPresent(user -> { + String reIssuedRefreshToken = reIssueRefreshToken(user); + jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getEmail()), + reIssuedRefreshToken); + }); + } + + private String reIssueRefreshToken(User user) { + String reIssuedRefreshToken = jwtService.createRefreshToken(); + user.updateRefreshToken(reIssuedRefreshToken); + userRepository.saveAndFlush(user); + return reIssuedRefreshToken; + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/global/jwt/util/PasswordUtil.java b/classmate/src/main/java/devteamOne/classmate/global/jwt/util/PasswordUtil.java new file mode 100644 index 0000000..a64f7f7 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/jwt/util/PasswordUtil.java @@ -0,0 +1,30 @@ +package devteamOne.classmate.global.jwt.util; + +import java.util.Random; + +public class PasswordUtil { + public static String generateRandomPassword() { + + int index = 0; + + char[] charSet = new char[] { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', + 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', + 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z' + }; + + StringBuffer password = new StringBuffer(); + Random random = new Random(); + + for (int i = 0; i < 8 ; i++) { + double rd = random.nextDouble(); + index = (int) (charSet.length * rd); + + password.append(charSet[index]); + } + System.out.println(password); + return password.toString(); + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/global/login/filter/CustomJsonAuthenticationFilter.java b/classmate/src/main/java/devteamOne/classmate/global/login/filter/CustomJsonAuthenticationFilter.java new file mode 100644 index 0000000..8be9056 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/login/filter/CustomJsonAuthenticationFilter.java @@ -0,0 +1,55 @@ +package devteamOne.classmate.global.login.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +@Slf4j +public class CustomJsonAuthenticationFilter extends AbstractAuthenticationProcessingFilter { + + private static final String DEFAULT_LOGIN_REQUEST_URL = "/login"; + private static final String HTTP_METHOD = "POST"; + private static final String CONTENT_TYPE = "application/json"; + private static final String USERNAME = "email"; + private static final String PASSWORD = "password"; + private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER = + new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD); + private final ObjectMapper objectMapper; + + public CustomJsonAuthenticationFilter(ObjectMapper objectMapper) { + super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER); + this.objectMapper = objectMapper; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException { + + if(request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE) ) { + throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType()); + } + + String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); + + Map usernamePasswordMap = objectMapper.readValue(messageBody, Map.class); + + String email = usernamePasswordMap.get(USERNAME); + String password = usernamePasswordMap.get(PASSWORD); + + UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(email, password); + + log.info("인증 요청 - email : {}, pwd : {}", email, password ); + return this.getAuthenticationManager().authenticate(authRequest); + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/global/login/handler/LoginFailureHandler.java b/classmate/src/main/java/devteamOne/classmate/global/login/handler/LoginFailureHandler.java new file mode 100644 index 0000000..a74b342 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/login/handler/LoginFailureHandler.java @@ -0,0 +1,23 @@ +package devteamOne.classmate.global.login.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; + +import java.io.IOException; + +@Slf4j +public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setCharacterEncoding("UTF-8"); + response.setContentType("text/plain;charset=UTF-8"); + response.getWriter().write("로그인 실패"); + log.info("로그인에 실패했습니다. : {}", exception.getMessage()); + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/global/login/handler/LoginSuccessHandler.java b/classmate/src/main/java/devteamOne/classmate/global/login/handler/LoginSuccessHandler.java new file mode 100644 index 0000000..53c40bd --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/login/handler/LoginSuccessHandler.java @@ -0,0 +1,52 @@ +package devteamOne.classmate.global.login.handler; + +import devteamOne.classmate.global.jwt.JwtService; +import devteamOne.classmate.user.repository.UserRepository; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtService jwtService; + private final UserRepository userRepository; + + @Value("${jwt.access.expiration}") + private String accessTokenExpiration; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + + String email = extractUsername(authentication); + String accessToken = jwtService.createAccessToken(email); + String refreshToken = jwtService.createRefreshToken(); + + jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken); + + userRepository.findByEmail(email) + .ifPresent(user -> { + user.updateRefreshToken(refreshToken); + userRepository.saveAndFlush(user); + }); + + log.info("로그인에 성공하였습니다. 이메일 : {}", email); + log.info("로그인에 성공하였습니다. AccessToken : {}", accessToken); + log.info("발급된 AccessToken 만료 기간 : {}", accessTokenExpiration); + } + + private String extractUsername(Authentication authentication) { + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + return userDetails.getUsername(); + } +} + diff --git a/classmate/src/main/java/devteamOne/classmate/global/login/service/LoginService.java b/classmate/src/main/java/devteamOne/classmate/global/login/service/LoginService.java new file mode 100644 index 0000000..002f0b3 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/login/service/LoginService.java @@ -0,0 +1,28 @@ +package devteamOne.classmate.global.login.service; + +import devteamOne.classmate.user.domain.User; +import devteamOne.classmate.user.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; + +@Service +@RequiredArgsConstructor +public class LoginService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("해당 이메일이 존재하지 않습니다.")); + + return org.springframework.security.core.userdetails.User.builder() + .username(user.getEmail()) + .password(user.getPassword()) + .roles(user.getRole().name()) + .build(); + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/global/oauth/CustomOAuth2User.java b/classmate/src/main/java/devteamOne/classmate/global/oauth/CustomOAuth2User.java new file mode 100644 index 0000000..84e3cc0 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/oauth/CustomOAuth2User.java @@ -0,0 +1,24 @@ +package devteamOne.classmate.global.oauth; + +import devteamOne.classmate.user.domain.Role; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +import java.util.Collection; +import java.util.Map; + +@Getter +public class CustomOAuth2User extends DefaultOAuth2User { + + private String email; + private Role role; + + public CustomOAuth2User(Collection authorities, + Map attributes, String nameAttributeKey, + String email, Role role) { + super(authorities, attributes, nameAttributeKey); + this.email = email; + this.role = role; + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/global/oauth/OAuthAttributes.java b/classmate/src/main/java/devteamOne/classmate/global/oauth/OAuthAttributes.java new file mode 100644 index 0000000..e939305 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/oauth/OAuthAttributes.java @@ -0,0 +1,51 @@ +package devteamOne.classmate.global.oauth; + +import devteamOne.classmate.global.oauth.userinfo.GoogleOAuth2UserInfo; +import devteamOne.classmate.global.oauth.userinfo.OAuth2UserInfo; +import devteamOne.classmate.user.domain.Role; +import devteamOne.classmate.user.domain.SocialType; +import devteamOne.classmate.user.domain.User; +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; +import java.util.UUID; + +@Getter +public class OAuthAttributes { + + private String nameAttributeKey; + private OAuth2UserInfo oAuth2UserInfo; + + @Builder + public OAuthAttributes(String nameAttributeKey, OAuth2UserInfo oAuth2UserInfo) { + this.nameAttributeKey = nameAttributeKey; + this.oAuth2UserInfo = oAuth2UserInfo; + } + + public static OAuthAttributes of(SocialType socialType, + String userNameAttributeName, Map attributes) { + + if (socialType == SocialType.GOOGLE) { + return ofGoogle(userNameAttributeName, attributes); + } + return null; + } + + private static OAuthAttributes ofGoogle(String userNameAttributeName, Map attributes) { + return OAuthAttributes.builder() + .nameAttributeKey(userNameAttributeName) + .oAuth2UserInfo(new GoogleOAuth2UserInfo(attributes)) + .build(); + } + + public User toEntity(SocialType socialType, OAuth2UserInfo oAuth2UserInfo) { + return User.builder() + .socialType(socialType) + .socialId(oAuth2UserInfo.getId()) + .email(UUID.randomUUID() + "@socialUser.com") + .nickname(oAuth2UserInfo.getNickname()) + .role(Role.GUEST) + .build(); + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/global/oauth/handler/OAuth2LoginFailureHandler.java b/classmate/src/main/java/devteamOne/classmate/global/oauth/handler/OAuth2LoginFailureHandler.java new file mode 100644 index 0000000..437c0f0 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/oauth/handler/OAuth2LoginFailureHandler.java @@ -0,0 +1,22 @@ +package devteamOne.classmate.global.oauth.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler { + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("Social Login Fail"); + log.info("Social Login Fail : {}", exception.getMessage()); + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/global/oauth/handler/OAuth2LoginSuccessHandler.java b/classmate/src/main/java/devteamOne/classmate/global/oauth/handler/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..16cfe4d --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/oauth/handler/OAuth2LoginSuccessHandler.java @@ -0,0 +1,57 @@ +package devteamOne.classmate.global.oauth.handler; + +import devteamOne.classmate.global.jwt.JwtService; +import devteamOne.classmate.global.oauth.CustomOAuth2User; +import devteamOne.classmate.user.domain.Role; +import devteamOne.classmate.user.repository.UserRepository; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtService jwtService; + private final UserRepository userRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + log.info("OAuth2 Login Success"); + + try { + CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal(); + + if (oAuth2User.getRole() == Role.GUEST) { + String accessToken = jwtService.createAccessToken(oAuth2User.getEmail()); + response.addHeader(jwtService.getAccessHeader(), "Bearer " + accessToken); +// response.sendRedirect("oauth2/sign-up"); + + jwtService.sendAccessAndRefreshToken(response, accessToken, null); + } else { + loginSuccess(response, oAuth2User); + } + } catch (Exception e) { + log.info("OAuth2LoginSuccessHandler Exception : {}", e.getMessage()); + throw e; + } + } + + private void loginSuccess(HttpServletResponse response, CustomOAuth2User oAuth2User) { + String accessToken = jwtService.createAccessToken(oAuth2User.getEmail()); + String refreshToken = jwtService.createRefreshToken(); + response.addHeader(jwtService.getAccessHeader(), "Bearer " + accessToken); + response.addHeader(jwtService.getRefreshHeader(), "Bearer " + refreshToken); + + jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken); + jwtService.updateRefreshToken(oAuth2User.getEmail(), refreshToken); + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/global/oauth/service/CustomOAuth2UserService.java b/classmate/src/main/java/devteamOne/classmate/global/oauth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..60d9864 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/oauth/service/CustomOAuth2UserService.java @@ -0,0 +1,77 @@ +package devteamOne.classmate.global.oauth.service; + +import devteamOne.classmate.global.oauth.CustomOAuth2User; +import devteamOne.classmate.global.oauth.OAuthAttributes; +import devteamOne.classmate.user.domain.SocialType; +import devteamOne.classmate.user.domain.User; +import devteamOne.classmate.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + + private final UserRepository userRepository; + + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + log.info("Call CustomOAuth2UserService.loadUser() - OAuth2 로그인 요청"); + + OAuth2UserService delegate = new DefaultOAuth2UserService(); + OAuth2User oAuth2User = delegate.loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + SocialType socialType = getSocialType(registrationId); + String userNameAttributeName = userRequest.getClientRegistration() + .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); + + Map attributes = oAuth2User.getAttributes(); + OAuthAttributes extractAttributes = OAuthAttributes.of(socialType, userNameAttributeName, attributes); + + User createdUser = getUser(extractAttributes, socialType); + + return new CustomOAuth2User( + Collections.singleton(new SimpleGrantedAuthority(createdUser.getRole().getKey())), + attributes, + extractAttributes.getNameAttributeKey(), + createdUser.getEmail(), + createdUser.getRole() + ); + } + + private User getUser(OAuthAttributes attributes, SocialType socialType) { + User findUser = userRepository.findBySocialTypeAndSocialId(socialType, + attributes.getOAuth2UserInfo().getId()).orElse(null); + + if (findUser == null) { + return saveUser(attributes, socialType); + } + + return findUser; + } + + private User saveUser(OAuthAttributes attributes, SocialType socialType) { + User createdUser = attributes.toEntity(socialType, attributes.getOAuth2UserInfo()); + return userRepository.save(createdUser); + } + + private SocialType getSocialType(String registrationId) { + if (registrationId.equals("google")) { + return SocialType.GOOGLE; + } + return null; + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/global/oauth/userinfo/GoogleOAuth2UserInfo.java b/classmate/src/main/java/devteamOne/classmate/global/oauth/userinfo/GoogleOAuth2UserInfo.java new file mode 100644 index 0000000..9f7fab0 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/oauth/userinfo/GoogleOAuth2UserInfo.java @@ -0,0 +1,19 @@ +package devteamOne.classmate.global.oauth.userinfo; + +import java.util.Map; + +public class GoogleOAuth2UserInfo extends OAuth2UserInfo { + public GoogleOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return (String) attributes.get("sub"); + } + + @Override + public String getNickname() { + return (String) attributes.get("name"); + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/global/oauth/userinfo/OAuth2UserInfo.java b/classmate/src/main/java/devteamOne/classmate/global/oauth/userinfo/OAuth2UserInfo.java new file mode 100644 index 0000000..3fc5e7e --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/global/oauth/userinfo/OAuth2UserInfo.java @@ -0,0 +1,15 @@ +package devteamOne.classmate.global.oauth.userinfo; + +import java.util.Map; + +public abstract class OAuth2UserInfo { + + protected Map attributes; + + public OAuth2UserInfo(Map attributes) { + this.attributes = attributes; + } + + public abstract String getId(); + public abstract String getNickname(); +} diff --git a/classmate/src/main/java/devteamOne/classmate/user/controller/UserController.java b/classmate/src/main/java/devteamOne/classmate/user/controller/UserController.java new file mode 100644 index 0000000..2bf9463 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/user/controller/UserController.java @@ -0,0 +1,33 @@ +package devteamOne.classmate.user.controller; + +import devteamOne.classmate.global.response.BasicResponse; +import devteamOne.classmate.user.domain.dto.UserSignUpDto; +import devteamOne.classmate.user.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + + @PostMapping("/sign-up") + public ResponseEntity signUp(@RequestBody UserSignUpDto userSignUpDto) { + userService.signUp(userSignUpDto); + + return ResponseEntity.ok( + BasicResponse.from("S-00", "회원 가입 성공") + ); + } + + @GetMapping("/jwt-test") + public String jwtTest() { + return "jwtTest 요청 성공"; + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/user/domain/Role.java b/classmate/src/main/java/devteamOne/classmate/user/domain/Role.java new file mode 100644 index 0000000..64beb11 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/user/domain/Role.java @@ -0,0 +1,13 @@ +package devteamOne.classmate.user.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + + GUEST("ROLE_GUEST"), USER("ROLE_USER"); + + private final String key; +} diff --git a/classmate/src/main/java/devteamOne/classmate/user/domain/SocialType.java b/classmate/src/main/java/devteamOne/classmate/user/domain/SocialType.java new file mode 100644 index 0000000..6f7adb0 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/user/domain/SocialType.java @@ -0,0 +1,5 @@ +package devteamOne.classmate.user.domain; + +public enum SocialType { + GOOGLE +} diff --git a/classmate/src/main/java/devteamOne/classmate/user/domain/User.java b/classmate/src/main/java/devteamOne/classmate/user/domain/User.java index b69c53f..b91da67 100644 --- a/classmate/src/main/java/devteamOne/classmate/user/domain/User.java +++ b/classmate/src/main/java/devteamOne/classmate/user/domain/User.java @@ -4,27 +4,44 @@ import devteamOne.classmate.global.domain.BaseTimeEntity; import devteamOne.classmate.question.domain.Question; import jakarta.persistence.*; -import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + import java.util.ArrayList; import java.util.List; -import org.springframework.boot.autoconfigure.condition.ConditionalOnJava; @Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor public class User extends BaseTimeEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String email; + private String nickname; + private String password; + private String socialId; + private String refreshToken; - private String nickname; + @Enumerated(EnumType.STRING) + private SocialType socialType; - private String socialId; + @Enumerated(EnumType.STRING) + private Role role; - private String provider; + @OneToMany(mappedBy = "user") + private List channelList = new ArrayList<>(); - @OneToMany(mappedBy = "user") - private List channelList = new ArrayList<>(); + @OneToMany(mappedBy = "user") + private List questionList = new ArrayList<>(); - @OneToMany(mappedBy = "user") - private List questionList = new ArrayList<>(); + public void updateRefreshToken(String updateRefreshToken) { + this.refreshToken = updateRefreshToken; + } } \ No newline at end of file diff --git a/classmate/src/main/java/devteamOne/classmate/user/domain/dto/UserSignUpDto.java b/classmate/src/main/java/devteamOne/classmate/user/domain/dto/UserSignUpDto.java new file mode 100644 index 0000000..e93c723 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/user/domain/dto/UserSignUpDto.java @@ -0,0 +1,14 @@ +package devteamOne.classmate.user.domain.dto; + +import jakarta.validation.constraints.Email; +import lombok.Getter; + +@Getter +public class UserSignUpDto { + + @Email + private String email; + + private String password; + private String nickname; +} diff --git a/classmate/src/main/java/devteamOne/classmate/user/exception/UserAlreadyExistException.java b/classmate/src/main/java/devteamOne/classmate/user/exception/UserAlreadyExistException.java new file mode 100644 index 0000000..b9c75e5 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/user/exception/UserAlreadyExistException.java @@ -0,0 +1,6 @@ +package devteamOne.classmate.user.exception; + +public class UserAlreadyExistException extends RuntimeException { + public UserAlreadyExistException() { + } +} diff --git a/classmate/src/main/java/devteamOne/classmate/user/repository/UserRepository.java b/classmate/src/main/java/devteamOne/classmate/user/repository/UserRepository.java index 96da53b..28a9f68 100644 --- a/classmate/src/main/java/devteamOne/classmate/user/repository/UserRepository.java +++ b/classmate/src/main/java/devteamOne/classmate/user/repository/UserRepository.java @@ -1,7 +1,15 @@ package devteamOne.classmate.user.repository; +import devteamOne.classmate.user.domain.SocialType; import devteamOne.classmate.user.domain.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + Optional findByNickname(String nickname); + Optional findByRefreshToken(String refreshToken); + Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId); } diff --git a/classmate/src/main/java/devteamOne/classmate/user/service/UserService.java b/classmate/src/main/java/devteamOne/classmate/user/service/UserService.java new file mode 100644 index 0000000..e2ec889 --- /dev/null +++ b/classmate/src/main/java/devteamOne/classmate/user/service/UserService.java @@ -0,0 +1,36 @@ +package devteamOne.classmate.user.service; + +import devteamOne.classmate.user.domain.Role; +import devteamOne.classmate.user.domain.User; +import devteamOne.classmate.user.domain.dto.UserSignUpDto; +import devteamOne.classmate.user.exception.UserAlreadyExistException; +import devteamOne.classmate.user.repository.UserRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@Transactional +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public void signUp(UserSignUpDto userSignUpDto) { + + if (userRepository.findByEmail(userSignUpDto.getEmail()).isPresent()) { + throw new UserAlreadyExistException(); + } + + User user = User.builder() + .email(userSignUpDto.getEmail()) + .nickname(userSignUpDto.getNickname()) + .password(passwordEncoder.encode(userSignUpDto.getPassword())) + .role(Role.USER) + .build(); + + userRepository.save(user); + } +} diff --git a/classmate/src/main/resources/application-jwt.yml b/classmate/src/main/resources/application-jwt.yml new file mode 100644 index 0000000..ae4bddd --- /dev/null +++ b/classmate/src/main/resources/application-jwt.yml @@ -0,0 +1,14 @@ +profiles: + include: + - API-KEY + +jwt: + secretKey: ${JWT-SECRET-KEY} + + access: + expiration: 3600000 # 1시간 + header: Authorization + + refresh: + expiration: 1209600000 # 2주 + header: Authorization-refresh \ No newline at end of file diff --git a/classmate/src/main/resources/application-oauth.yml b/classmate/src/main/resources/application-oauth.yml new file mode 100644 index 0000000..5f99a8e --- /dev/null +++ b/classmate/src/main/resources/application-oauth.yml @@ -0,0 +1,13 @@ +profiles: + include: + - API-KEY + +spring: + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE-OAUTH-CLIENT-ID} + client-secret: ${GOOGLE-OAUTH-SECRET} + scope: profile, email \ No newline at end of file diff --git a/classmate/src/main/resources/application.yml b/classmate/src/main/resources/application.yml index 4d4083c..f0eab76 100644 --- a/classmate/src/main/resources/application.yml +++ b/classmate/src/main/resources/application.yml @@ -10,7 +10,10 @@ spring: ddl-auto: create profiles: - include: API-KEY + include: + - API-KEY + - jwt + - oauth logging.level: org.hibernate.SQL: debug