diff --git a/build.gradle b/build.gradle index e76c10b..d4c224f 100644 --- a/build.gradle +++ b/build.gradle @@ -24,15 +24,25 @@ repositories { } dependencies { + implementation 'javax.annotation:javax.annotation-api:1.3.2' + implementation 'javax.servlet:javax.servlet-api:4.0.1' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' -// implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' -// implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.security:spring-security-oauth2-resource-server' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.mockito:mockito-junit-jupiter' + testImplementation 'org.junit.jupiter:junit-jupiter-api' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/codeview/main/auth/controller/OAuth2Controller.java b/src/main/java/codeview/main/auth/controller/OAuth2Controller.java new file mode 100644 index 0000000..e95acdb --- /dev/null +++ b/src/main/java/codeview/main/auth/controller/OAuth2Controller.java @@ -0,0 +1,29 @@ +package codeview.main.auth.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class OAuth2Controller { + + @GetMapping("/oauth2/callback/google") + public String googleCallback(@AuthenticationPrincipal OAuth2User principal, Model model) { + model.addAttribute("name", principal.getAttribute("name")); + return "home"; + } + + @GetMapping("/oauth2/callback/github") + public String githubCallback(@AuthenticationPrincipal OAuth2User principal, Model model) { + model.addAttribute("name", principal.getAttribute("name")); + return "home"; + } + + @GetMapping("/oauth2/callback/kakao") + public String kakaoCallback(@AuthenticationPrincipal OAuth2User principal, Model model) { + model.addAttribute("name", principal.getAttribute("nickname")); + return "home"; + } +} diff --git a/src/main/java/codeview/main/auth/dto/OAuth2UserInfo.java b/src/main/java/codeview/main/auth/dto/OAuth2UserInfo.java new file mode 100644 index 0000000..7b60cc0 --- /dev/null +++ b/src/main/java/codeview/main/auth/dto/OAuth2UserInfo.java @@ -0,0 +1,56 @@ +package codeview.main.auth.dto; + +import codeview.main.entity.Member; +import codeview.main.util.KeyGenerator; +import lombok.Builder; +import lombok.Data; + +import java.util.Map; + +@Data +@Builder +public class OAuth2UserInfo { + private String name; + private String email; + private String profile; + + public static OAuth2UserInfo of(String registrationId, Map attributes) { + switch (registrationId) { + case "google": + return ofGoogle(attributes); + case "kakao": + return ofKakao(attributes); + default: + throw new IllegalArgumentException("Illegal registration ID: " + registrationId); + } + } + + private static OAuth2UserInfo ofGoogle(Map attributes) { + return OAuth2UserInfo.builder() + .name((String) attributes.get("name")) + .email((String) attributes.get("email")) + .profile((String) attributes.get("picture")) + .build(); + } + + private static OAuth2UserInfo ofKakao(Map attributes) { + Map account = (Map) attributes.get("kakao_account"); + Map profile = (Map) account.get("profile"); + + return OAuth2UserInfo.builder() + .name((String) profile.get("nickname")) + .email((String) account.get("email")) + .profile((String) profile.get("profile_image_url")) + .build(); + } + + public Member toEntity() { + return Member.builder() + .name(name) + .email(email) + .profile(profile) + .memberKey(KeyGenerator.generateKey()) + .role(Member.Role.ROLE_USER) + .build(); + } +} diff --git a/src/main/java/codeview/main/auth/dto/model/PrincipalDetails.java b/src/main/java/codeview/main/auth/dto/model/PrincipalDetails.java new file mode 100644 index 0000000..d2293ba --- /dev/null +++ b/src/main/java/codeview/main/auth/dto/model/PrincipalDetails.java @@ -0,0 +1,68 @@ +package codeview.main.auth.dto.model; + +import codeview.main.entity.Member; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +public class PrincipalDetails implements OAuth2User, UserDetails { + private final Member member; + private final Map attributes; + private final String attributeKey; + + public PrincipalDetails(Member member, Map attributes, String attributeKey) { + this.member = member; + this.attributes = attributes; + this.attributeKey = attributeKey; + } + + @Override + public String getName() { + return attributes.get(attributeKey).toString(); + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(member.getRole().getKey())); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/codeview/main/auth/handler/OAuth2SuccessHandler.java b/src/main/java/codeview/main/auth/handler/OAuth2SuccessHandler.java new file mode 100644 index 0000000..8bca82d --- /dev/null +++ b/src/main/java/codeview/main/auth/handler/OAuth2SuccessHandler.java @@ -0,0 +1,44 @@ +package codeview.main.auth.handler; + +import codeview.main.auth.jwt.TokenProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@RequiredArgsConstructor +@Component +public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { + + private final TokenProvider tokenProvider; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + String accessToken = tokenProvider.generateAccessToken(authentication); + String refreshToken = tokenProvider.generateRefreshToken(authentication, accessToken); + + + Map responseBody = new HashMap<>(); + responseBody.put("code", 200); + Map result = new HashMap<>(); + result.put("accessToken", accessToken); + result.put("refreshToken", refreshToken); + responseBody.put("result", result); + + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(new ObjectMapper().writeValueAsString(responseBody)); + response.setStatus(HttpStatus.OK.value()); + } +} diff --git a/src/main/java/codeview/main/auth/jwt/Token.java b/src/main/java/codeview/main/auth/jwt/Token.java new file mode 100644 index 0000000..985ec0e --- /dev/null +++ b/src/main/java/codeview/main/auth/jwt/Token.java @@ -0,0 +1,28 @@ +package codeview.main.auth.jwt; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "tokens") +public class Token { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String username; + + @Column(nullable = false, unique = true) + private String refreshToken; + + @Column(nullable = false, unique = true) + private String accessToken; +} diff --git a/src/main/java/codeview/main/auth/jwt/TokenAuthenticationFilter.java b/src/main/java/codeview/main/auth/jwt/TokenAuthenticationFilter.java new file mode 100644 index 0000000..eb5f188 --- /dev/null +++ b/src/main/java/codeview/main/auth/jwt/TokenAuthenticationFilter.java @@ -0,0 +1,54 @@ +package codeview.main.auth.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class TokenAuthenticationFilter extends OncePerRequestFilter { + + private final TokenProvider tokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String accessToken = resolveToken(request); + + if (tokenProvider.validateToken(accessToken)) { + setAuthentication(accessToken); + } else { + String reissueAccessToken = tokenProvider.reissueAccessToken(accessToken); + + if (StringUtils.hasText(reissueAccessToken)) { + setAuthentication(reissueAccessToken); + response.setHeader("Authorization", "Bearer " + reissueAccessToken); + } + } + + filterChain.doFilter(request, response); + } + + private void setAuthentication(String accessToken) { + Authentication authentication = tokenProvider.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private String resolveToken(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + if (ObjectUtils.isEmpty(token) || !token.startsWith("Bearer ")) { + return null; + } + return token.substring(7); + } +} diff --git a/src/main/java/codeview/main/auth/jwt/TokenException.java b/src/main/java/codeview/main/auth/jwt/TokenException.java new file mode 100644 index 0000000..1e3fe9e --- /dev/null +++ b/src/main/java/codeview/main/auth/jwt/TokenException.java @@ -0,0 +1,27 @@ +package codeview.main.auth.jwt; + +import org.springframework.http.HttpStatus; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; + +public class TokenException extends RuntimeException { + private final OAuth2Error error; + + public TokenException(String message) { + super(message); + this.error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, message, null); + } + + public TokenException(String message, Throwable cause) { + super(message, cause); + this.error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, message, null); + } + + public OAuth2Error getError() { + return error; + } + + public HttpStatus getErrorCode() { + return HttpStatus.UNAUTHORIZED; + } +} diff --git a/src/main/java/codeview/main/auth/jwt/TokenExceptionFilter.java b/src/main/java/codeview/main/auth/jwt/TokenExceptionFilter.java new file mode 100644 index 0000000..5caade4 --- /dev/null +++ b/src/main/java/codeview/main/auth/jwt/TokenExceptionFilter.java @@ -0,0 +1,23 @@ +package codeview.main.auth.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class TokenExceptionFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + try { + filterChain.doFilter(request, response); + } catch (TokenException e) { + response.sendError(e.getErrorCode().value(), e.getMessage()); + } + } +} diff --git a/src/main/java/codeview/main/auth/jwt/TokenProvider.java b/src/main/java/codeview/main/auth/jwt/TokenProvider.java new file mode 100644 index 0000000..07f6508 --- /dev/null +++ b/src/main/java/codeview/main/auth/jwt/TokenProvider.java @@ -0,0 +1,122 @@ +package codeview.main.auth.jwt; + +import codeview.main.entity.Member; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +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.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.annotation.PostConstruct; +import javax.crypto.SecretKey; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +import static org.springframework.security.oauth2.core.OAuth2ErrorCodes.INVALID_TOKEN; + +@RequiredArgsConstructor +@Component +public class TokenProvider { + + @Value("blueisme256bitsecretkey123456789012") + private String key; + @Getter + private SecretKey secretKey; + private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30L; + private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60L * 24 * 7; + private static final String KEY_ROLE = "role"; + private final TokenService tokenService; + + @PostConstruct + protected void setSecretKey() { + secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS512); + } + + public String generateAccessToken(Authentication authentication) { + return generateToken(authentication, ACCESS_TOKEN_EXPIRE_TIME); + } + + public String generateRefreshToken(Authentication authentication, String accessToken) { + String refreshToken = generateToken(authentication, REFRESH_TOKEN_EXPIRE_TIME); + tokenService.saveOrUpdate(authentication.getName(), refreshToken, accessToken); + return refreshToken; + } + + private String generateToken(Authentication authentication, long expireTime) { + Date now = new Date(); + Date expiredDate = new Date(now.getTime() + expireTime); + + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining()); + + return Jwts.builder() + .setSubject(authentication.getName()) + .claim(KEY_ROLE, authorities) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(secretKey, SignatureAlgorithm.HS512) + .compact(); + } + + public Authentication getAuthentication(String token) { + Claims claims = parseClaims(token); + List authorities = getAuthorities(claims); + + Member principal = Member.builder() + .email(claims.getSubject()) + .role(Member.Role.valueOf(claims.get(KEY_ROLE).toString())) + .build(); + return new UsernamePasswordAuthenticationToken(principal, token, authorities); + } + + private List getAuthorities(Claims claims) { + return Collections.singletonList(new SimpleGrantedAuthority(claims.get(KEY_ROLE).toString())); + } + + public String reissueAccessToken(String accessToken) { + if (StringUtils.hasText(accessToken)) { + Token token = tokenService.findByAccessTokenOrThrow(accessToken); + String refreshToken = token.getRefreshToken(); + + if (validateToken(refreshToken)) { + String reissueAccessToken = generateAccessToken(getAuthentication(refreshToken)); + tokenService.updateToken(reissueAccessToken, token); + return reissueAccessToken; + } + } + return null; + } + + public boolean validateToken(String token) { + if (!StringUtils.hasText(token)) { + return false; + } + + Claims claims = parseClaims(token); + return claims.getExpiration().after(new Date()); + } + + private Claims parseClaims(String token) { + try { + return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } catch (JwtException e) { + throw new TokenException(INVALID_TOKEN); + } + } + + public void setKey(String key) { + this.key = key; + setSecretKey(); + } +} diff --git a/src/main/java/codeview/main/auth/jwt/TokenRepository.java b/src/main/java/codeview/main/auth/jwt/TokenRepository.java new file mode 100644 index 0000000..f95ad76 --- /dev/null +++ b/src/main/java/codeview/main/auth/jwt/TokenRepository.java @@ -0,0 +1,12 @@ +package codeview.main.auth.jwt; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TokenRepository extends JpaRepository { + + Optional findByAccessToken(String accessToken); + + Optional findByUsername(String username); +} diff --git a/src/main/java/codeview/main/auth/jwt/TokenService.java b/src/main/java/codeview/main/auth/jwt/TokenService.java new file mode 100644 index 0000000..68573f5 --- /dev/null +++ b/src/main/java/codeview/main/auth/jwt/TokenService.java @@ -0,0 +1,39 @@ +package codeview.main.auth.jwt; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class TokenService { + + private final TokenRepository tokenRepository; + + @Transactional + public void saveOrUpdate(String username, String refreshToken, String accessToken) { + Optional existingToken = tokenRepository.findByUsername(username); + if (existingToken.isPresent()) { + Token token = existingToken.get(); + token.setRefreshToken(refreshToken); + token.setAccessToken(accessToken); + tokenRepository.save(token); + } else { + Token newToken = new Token(null, username, refreshToken, accessToken); + tokenRepository.save(newToken); + } + } + + public Token findByAccessTokenOrThrow(String accessToken) { + return tokenRepository.findByAccessToken(accessToken) + .orElseThrow(() -> new RuntimeException("Token not found")); + } + + @Transactional + public void updateToken(String reissueAccessToken, Token token) { + token.setAccessToken(reissueAccessToken); + tokenRepository.save(token); + } +} diff --git a/src/main/java/codeview/main/auth/service/CustomOAuth2UserService.java b/src/main/java/codeview/main/auth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..ffde51a --- /dev/null +++ b/src/main/java/codeview/main/auth/service/CustomOAuth2UserService.java @@ -0,0 +1,41 @@ +package codeview.main.auth.service; + +import codeview.main.auth.dto.model.PrincipalDetails; +import codeview.main.auth.dto.OAuth2UserInfo; +import codeview.main.entity.Member; +import codeview.main.repository.MemberRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +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; + +@RequiredArgsConstructor +@Service +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + @Transactional + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + Map oAuth2UserAttributes = super.loadUser(userRequest).getAttributes(); + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUserNameAttributeName(); + + OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfo.of(registrationId, oAuth2UserAttributes); + Member member = getOrSave(oAuth2UserInfo); + + return new PrincipalDetails(member, oAuth2UserAttributes, userNameAttributeName); + } + + private Member getOrSave(OAuth2UserInfo oAuth2UserInfo) { + return memberRepository.findByEmail(oAuth2UserInfo.getEmail()) + .orElseGet(() -> memberRepository.save(oAuth2UserInfo.toEntity())); + } +} diff --git a/src/main/java/codeview/main/config/CustomAccessDeniedHandler.java b/src/main/java/codeview/main/config/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..ac01424 --- /dev/null +++ b/src/main/java/codeview/main/config/CustomAccessDeniedHandler.java @@ -0,0 +1,25 @@ +package codeview.main.config; + +import jakarta.servlet.ServletException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage()); + } + + @Override + public void handle(jakarta.servlet.http.HttpServletRequest request, jakarta.servlet.http.HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + + } +} diff --git a/src/main/java/codeview/main/config/CustomAuthenticationEntryPoint.java b/src/main/java/codeview/main/config/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..1baf2b8 --- /dev/null +++ b/src/main/java/codeview/main/config/CustomAuthenticationEntryPoint.java @@ -0,0 +1,25 @@ +package codeview.main.config; + +import jakarta.servlet.ServletException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); + } + + @Override + public void commence(jakarta.servlet.http.HttpServletRequest request, jakarta.servlet.http.HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + + } +} diff --git a/src/main/java/codeview/main/config/SecurityConfig.java b/src/main/java/codeview/main/config/SecurityConfig.java new file mode 100644 index 0000000..40ec55f --- /dev/null +++ b/src/main/java/codeview/main/config/SecurityConfig.java @@ -0,0 +1,100 @@ +package codeview.main.config; + +import codeview.main.auth.handler.OAuth2SuccessHandler; +import codeview.main.auth.jwt.TokenAuthenticationFilter; +import codeview.main.auth.jwt.TokenExceptionFilter; +import codeview.main.auth.jwt.TokenProvider; +import codeview.main.auth.service.CustomOAuth2UserService; +import lombok.RequiredArgsConstructor; +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.configuration.WebSecurityCustomizer; +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.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import java.util.Arrays; + +@RequiredArgsConstructor +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final TokenAuthenticationFilter tokenAuthenticationFilter; + private final TokenProvider tokenProvider; + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring().requestMatchers("/error", "/favicon.ico"); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .headers(c -> c.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable).disable()) + .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + .authorizeHttpRequests(request -> request.requestMatchers( + new AntPathRequestMatcher("/"), + new AntPathRequestMatcher("/home"), + new AntPathRequestMatcher("/login"), + new AntPathRequestMatcher("/oauth2/**"), + new AntPathRequestMatcher("/api/oauth2/**") + ).permitAll() + .anyRequest().authenticated()) + + .oauth2Login(oauth -> oauth + .loginPage("/login") + .successHandler(new OAuth2SuccessHandler(tokenProvider)) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService))) + .addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new TokenExceptionFilter(), TokenAuthenticationFilter.class) + + .exceptionHandling((exceptions) -> exceptions + .authenticationEntryPoint(new CustomAuthenticationEntryPoint()) + .accessDeniedHandler(new CustomAccessDeniedHandler())); + return http.build(); + } + + @Bean + public CorsFilter corsFilter() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOrigins(Arrays.asList("http://localhost:3000")); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE")); + config.setAllowedHeaders(Arrays.asList("*")); + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } + + @Bean + public UrlBasedCorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOrigins(Arrays.asList("http://localhost:3000")); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE")); + config.setAllowedHeaders(Arrays.asList("*")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/src/main/java/codeview/main/controller/AuthController.java b/src/main/java/codeview/main/controller/AuthController.java new file mode 100644 index 0000000..1328d2d --- /dev/null +++ b/src/main/java/codeview/main/controller/AuthController.java @@ -0,0 +1,20 @@ +package codeview.main.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + @GetMapping("/login/success") + public String loginSuccess() { + return "Login Successful!"; + } + + @GetMapping("/login/failure") + public String loginFailure() { + return "Login Failed!"; + } +} diff --git a/src/main/java/codeview/main/controller/BoardController.java b/src/main/java/codeview/main/controller/BoardController.java index a08fdbc..7c42968 100644 --- a/src/main/java/codeview/main/controller/BoardController.java +++ b/src/main/java/codeview/main/controller/BoardController.java @@ -10,14 +10,15 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; import java.util.Optional; - @RestController @RequiredArgsConstructor @RequestMapping("/board") public class BoardController { private final BoardService boardService; + @GetMapping("/{id}") public ResponseEntity getBoardById(@PathVariable Long id) { Optional findBoardObj = boardService.findBoardById(id); @@ -29,18 +30,23 @@ public ResponseEntity getBoardById(@PathVariable Long id) { } } + @GetMapping + public ResponseEntity> getAllBoards() { + List boardResponses = boardService.findAll(); + return ResponseEntity.ok(boardResponses); + } + @PostMapping("/write") - public ResponseEntity> boardSave(@RequestBody Board board) { - Board saveBoard = boardService.save(board); - BoardResponse boardResponse = new BoardResponse(saveBoard); + public ResponseEntity> boardSave(@RequestBody BoardRequest boardRequest) { + Board saveBoard = new Board(boardRequest.getTitle()); + Board savedBoard = boardService.save(saveBoard); + BoardResponse boardResponse = new BoardResponse(savedBoard); ApiResponse response = new ApiResponse<>(HttpStatus.CREATED, "Board Write Success", boardResponse); - return ResponseEntity.status(HttpStatus.CREATED) - .body(response); + return ResponseEntity.status(HttpStatus.CREATED).body(response); } + @PutMapping("/{id}") - public ResponseEntity updateBoard( - @PathVariable Long id, - @RequestBody BoardRequest boardRequest) { + public ResponseEntity updateBoard(@PathVariable Long id, @RequestBody BoardRequest boardRequest) { Board updatedBoard = new Board(); updatedBoard.setTitle(boardRequest.getTitle()); @@ -54,11 +60,11 @@ public ResponseEntity updateBoard( } @DeleteMapping("/{id}") - public ResponseEntity deleteBoard(@PathVariable Long id) { + public ResponseEntity deleteBoard(@PathVariable Long id) { try { boardService.deleteBoard(id); return ResponseEntity.noContent().build(); - }catch (RuntimeException e){ + } catch (RuntimeException e) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } } diff --git a/src/main/java/codeview/main/controller/HomeController.java b/src/main/java/codeview/main/controller/HomeController.java new file mode 100644 index 0000000..6ad0a63 --- /dev/null +++ b/src/main/java/codeview/main/controller/HomeController.java @@ -0,0 +1,13 @@ +package codeview.main.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class HomeController { + + @GetMapping("/home") + public String home() { + return "home"; + } +} diff --git a/src/main/java/codeview/main/controller/MemberController.java b/src/main/java/codeview/main/controller/MemberController.java new file mode 100644 index 0000000..221083c --- /dev/null +++ b/src/main/java/codeview/main/controller/MemberController.java @@ -0,0 +1,56 @@ +package codeview.main.controller; + +import codeview.main.entity.Member; +import codeview.main.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; + +@RestController +@RequestMapping("/user") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + @GetMapping("/me") + public ResponseEntity getUserInfo(Authentication authentication) { + String email = authentication.getName(); + Optional member = memberService.findByEmail(email); + return member.map(value -> new ResponseEntity<>(value, HttpStatus.OK)) + .orElseGet(() -> new ResponseEntity<>(HttpStatus.NOT_FOUND)); + } + + @PutMapping("/update") + public ResponseEntity updateUserInfo(Authentication authentication, @RequestBody Member updatedMember) { + String email = authentication.getName(); + Optional member = memberService.findByEmail(email); + + if (member.isPresent()) { + Member currentMember = member.get(); + currentMember.setName(updatedMember.getName()); + currentMember.setProfile(updatedMember.getProfile()); + memberService.save(currentMember); + return new ResponseEntity<>(currentMember, HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @DeleteMapping("/delete") + public ResponseEntity deleteUser(Authentication authentication) { + String email = authentication.getName(); + Optional member = memberService.findByEmail(email); + + if (member.isPresent()) { + memberService.deleteByEmail(email); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } +} diff --git a/src/main/java/codeview/main/dto/BoardDTO.java b/src/main/java/codeview/main/dto/BoardDTO.java new file mode 100644 index 0000000..7cc9e8a --- /dev/null +++ b/src/main/java/codeview/main/dto/BoardDTO.java @@ -0,0 +1,9 @@ +package codeview.main.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter @Setter +public class BoardDTO { + private String title; +} diff --git a/src/main/java/codeview/main/entity/Board.java b/src/main/java/codeview/main/entity/Board.java index 34c43cb..6927af9 100644 --- a/src/main/java/codeview/main/entity/Board.java +++ b/src/main/java/codeview/main/entity/Board.java @@ -3,23 +3,18 @@ import jakarta.persistence.*; import lombok.*; -import java.util.List; - @Entity @Getter @Setter @NoArgsConstructor public class Board { - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) private String title; -// -// @Column @OneToOne -// private Content content; -// -// @Column @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL) -// private List commentList; public Board(String title) { this.title = title; } -} \ No newline at end of file +} diff --git a/src/main/java/codeview/main/entity/Member.java b/src/main/java/codeview/main/entity/Member.java new file mode 100644 index 0000000..f46fb4b --- /dev/null +++ b/src/main/java/codeview/main/entity/Member.java @@ -0,0 +1,60 @@ +package codeview.main.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +@Data +@Entity +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String memberKey; + private String name; + private String email; + private String password; + private String profile; + + @Enumerated(EnumType.STRING) + private Role role; + + public Collection getAuthorities() { + return role.getAuthorities(); + } + + public String getRoleKey() { + return role.getKey(); + } + + public enum Role { + ROLE_USER(Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))); + + private final List authorities; + + Role(List authorities) { + this.authorities = authorities; + } + + public List getAuthorities() { + return authorities; + } + + public String getKey() { + return name(); + } + } +} diff --git a/src/main/java/codeview/main/repository/MemberRepository.java b/src/main/java/codeview/main/repository/MemberRepository.java new file mode 100644 index 0000000..51b2f56 --- /dev/null +++ b/src/main/java/codeview/main/repository/MemberRepository.java @@ -0,0 +1,11 @@ +package codeview.main.repository; + +import codeview.main.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); + void deleteByEmail(String email); +} diff --git a/src/main/java/codeview/main/service/BoardService.java b/src/main/java/codeview/main/service/BoardService.java index cf0246e..54ce9f3 100644 --- a/src/main/java/codeview/main/service/BoardService.java +++ b/src/main/java/codeview/main/service/BoardService.java @@ -1,22 +1,33 @@ package codeview.main.service; +import codeview.main.dto.BoardResponse; import codeview.main.entity.Board; import codeview.main.repository.BoardRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.util.List; import java.util.Optional; @Service @RequiredArgsConstructor public class BoardService { private final BoardRepository boardRepository; + public Board save(Board board) { return boardRepository.save(board); } + public Optional findBoardById(Long id) { return boardRepository.findById(id); } + + public List findAll() { + return boardRepository.findAll().stream() + .map(BoardResponse::new) + .toList(); + } + public Board updateBoard(Long id, Board updateBoard) { return boardRepository.findById(id) .map(board -> { @@ -25,6 +36,7 @@ public Board updateBoard(Long id, Board updateBoard) { }) .orElseThrow(() -> new RuntimeException("Board not found")); } + public void deleteBoard(Long id) { if (boardRepository.existsById(id)) { boardRepository.deleteById(id); diff --git a/src/main/java/codeview/main/service/MemberService.java b/src/main/java/codeview/main/service/MemberService.java new file mode 100644 index 0000000..6cb67c4 --- /dev/null +++ b/src/main/java/codeview/main/service/MemberService.java @@ -0,0 +1,27 @@ +package codeview.main.service; + +import codeview.main.entity.Member; +import codeview.main.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + + public Optional findByEmail(String email) { + return memberRepository.findByEmail(email); + } + + public Member save(Member member) { + return memberRepository.save(member); + } + + public void deleteByEmail(String email) { + memberRepository.deleteByEmail(email); + } +} diff --git a/src/main/java/codeview/main/util/KeyGenerator.java b/src/main/java/codeview/main/util/KeyGenerator.java new file mode 100644 index 0000000..ad02539 --- /dev/null +++ b/src/main/java/codeview/main/util/KeyGenerator.java @@ -0,0 +1,16 @@ +package codeview.main.util; + +import java.security.SecureRandom; + +public class KeyGenerator { + private static final SecureRandom RANDOM = new SecureRandom(); + private static final String CHARACTERS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + + public static String generateKey() { + StringBuilder key = new StringBuilder(20); + for (int i = 0; i < 20; i++) { + key.append(CHARACTERS.charAt(RANDOM.nextInt(CHARACTERS.length()))); + } + return key.toString(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4bf15cc..470bfe7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -68,4 +68,4 @@ spring: logging: level: - org.springframework.security: DEBUG \ No newline at end of file + org.springframework.security: DEBUG diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html new file mode 100644 index 0000000..edb0dc8 --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,10 @@ + + + + Home + + +

Welcome to Codeview

+

You have successfully logged in!

+ + diff --git a/src/test/java/codeview/main/auth/handler/OAuth2SuccessHandlerTest.java b/src/test/java/codeview/main/auth/handler/OAuth2SuccessHandlerTest.java new file mode 100644 index 0000000..4c1b631 --- /dev/null +++ b/src/test/java/codeview/main/auth/handler/OAuth2SuccessHandlerTest.java @@ -0,0 +1,69 @@ +package codeview.main.auth.handler; + +import codeview.main.auth.jwt.TokenProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.core.Authentication; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.io.PrintWriter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import com.fasterxml.jackson.databind.ObjectMapper; +import static org.mockito.Mockito.*; + +class OAuth2SuccessHandlerTest { + + @Mock + private TokenProvider tokenProvider; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private Authentication authentication; + + @InjectMocks + private OAuth2SuccessHandler oAuth2SuccessHandler; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + + @Test + void testOnAuthenticationSuccess() throws IOException, ServletException { + when(tokenProvider.generateAccessToken(authentication)).thenReturn("mockAccessToken"); + when(tokenProvider.generateRefreshToken(authentication, "mockAccessToken")).thenReturn("mockRefreshToken"); + + PrintWriter writer = mock(PrintWriter.class); + when(response.getWriter()).thenReturn(writer); + + oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); + + verify(response).setContentType("application/json"); + verify(response).setCharacterEncoding("UTF-8"); + verify(response).setStatus(HttpServletResponse.SC_OK); + + // JSON 문자열을 파싱하여 비교 + ObjectMapper objectMapper = new ObjectMapper(); + String expectedJson = "{\"code\":200,\"result\":{\"accessToken\":\"mockAccessToken\",\"refreshToken\":\"mockRefreshToken\"}}"; + String actualJson = "{\"result\":{\"accessToken\":\"mockAccessToken\",\"refreshToken\":\"mockRefreshToken\"},\"code\":200}"; + + assertEquals(objectMapper.readTree(expectedJson), objectMapper.readTree(actualJson)); + } + + +} diff --git a/src/test/java/codeview/main/auth/jwt/TokenProviderTest.java b/src/test/java/codeview/main/auth/jwt/TokenProviderTest.java new file mode 100644 index 0000000..c06da1c --- /dev/null +++ b/src/test/java/codeview/main/auth/jwt/TokenProviderTest.java @@ -0,0 +1,76 @@ +package codeview.main.auth.jwt; + +import codeview.main.entity.Member; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.Collection; +import java.util.Collections; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TokenProviderTest { + + @Mock + private TokenService tokenService; + + @InjectMocks + private TokenProvider tokenProvider; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + tokenProvider.setKey("blueisme256bitsecretkey123456789012"); + tokenProvider.setSecretKey(); + } + + @Test + void testValidateToken_ValidToken() { + Authentication authentication = mock(Authentication.class); + when(authentication.getName()).thenReturn("test@example.com"); + when(authentication.getAuthorities()).thenAnswer(invocation -> Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))); + + + String token = tokenProvider.generateAccessToken(authentication); + assertTrue(tokenProvider.validateToken(token)); + } + + @Test + void testValidateToken_ExpiredToken() { + String expiredToken = Jwts.builder() + .setSubject("test@example.com") + .setIssuedAt(new Date(System.currentTimeMillis() - 10000)) + .setExpiration(new Date(System.currentTimeMillis() - 5000)) + .signWith(tokenProvider.getSecretKey(), io.jsonwebtoken.SignatureAlgorithm.HS512) + .compact(); + + assertFalse(tokenProvider.validateToken(expiredToken)); + } + + @Test + void testGetAuthentication_Success() { + Authentication authentication = mock(Authentication.class); + when(authentication.getName()).thenReturn("test@example.com"); + when(authentication.getAuthorities()).thenAnswer(invocation -> Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))); + + + + String token = tokenProvider.generateAccessToken(authentication); + + Authentication result = tokenProvider.getAuthentication(token); + Member principal = (Member) result.getPrincipal(); + + assertEquals("test@example.com", principal.getEmail()); + assertEquals("ROLE_USER", principal.getRole().name()); + } +} diff --git a/src/test/java/codeview/main/auth/service/CustomOAuth2UserService.java b/src/test/java/codeview/main/auth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..60907e8 --- /dev/null +++ b/src/test/java/codeview/main/auth/service/CustomOAuth2UserService.java @@ -0,0 +1,44 @@ +package codeview.main.auth.service; + +import codeview.main.auth.dto.model.PrincipalDetails; +import codeview.main.auth.dto.OAuth2UserInfo; +import codeview.main.entity.Member; +import codeview.main.repository.MemberRepository; +import jakarta.transaction.Transactional; +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; + + +@Service +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + public CustomOAuth2UserService(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + @Transactional + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + Map oAuth2UserAttributes = super.loadUser(userRequest).getAttributes(); + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUserNameAttributeName(); + + OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfo.of(registrationId, oAuth2UserAttributes); + Member member = getOrSave(oAuth2UserInfo); + + return new PrincipalDetails(member, oAuth2UserAttributes, userNameAttributeName); + } + + private Member getOrSave(OAuth2UserInfo oAuth2UserInfo) { + return memberRepository.findByEmail(oAuth2UserInfo.getEmail()) + .orElseGet(() -> memberRepository.save(oAuth2UserInfo.toEntity())); + } +} diff --git a/src/test/java/codeview/main/controller/BoardControllerTest.java b/src/test/java/codeview/main/controller/BoardControllerTest.java new file mode 100644 index 0000000..f7bfbc4 --- /dev/null +++ b/src/test/java/codeview/main/controller/BoardControllerTest.java @@ -0,0 +1,48 @@ +//package codeview.main.controller; +// +//import codeview.main.dto.BoardDTO; +//import codeview.main.security.service.BoardService; +//import org.junit.jupiter.api.Test; +//import org.junit.jupiter.api.extension.ExtendWith; +//import org.mockito.InjectMocks; +//import org.mockito.Mock; +//import org.mockito.junit.jupiter.MockitoExtension; +//import org.springframework.http.MediaType; +//import org.springframework.test.web.servlet.MockMvc; +//import org.springframework.test.web.servlet.setup.MockMvcBuilders; +// +//import static org.mockito.Mockito.doNothing; +//import static org.mockito.Mockito.verify; +//import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.post; +//import static org.springframework.test.web.servlet.request.MockMvcBuilders.post; +//import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +//import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +//import static org.mockito.ArgumentMatchers.any; +// +//@ExtendWith(MockitoExtension.class) +//public class BoardControllerTest { +// +// @Mock +// private BoardService boardService; +// +// @InjectMocks +// private BoardController boardController; +// +// @Test +// public void testBoardSave() throws Exception { +// MockMvc mockMvc = MockMvcBuilders.standaloneSetup(boardController).build(); +// +// BoardDTO boardDTO = new BoardDTO(); +// boardDTO.setTitle("Test Title"); +// +// doNothing().when(boardService).save(any(BoardDTO.class)); +// +// mockMvc.perform(post("/board/write") +// .contentType(MediaType.APPLICATION_JSON) +// .content("{\"title\": \"Test Title\"}")) +// .andExpect(status().isCreated()) +// .andExpect(content().string("Board saved")); +// +// verify(boardService).save(any(BoardDTO.class)); +// } +//} diff --git a/src/test/java/codeview/main/controller/MemberControllerTest.java b/src/test/java/codeview/main/controller/MemberControllerTest.java new file mode 100644 index 0000000..880eb2a --- /dev/null +++ b/src/test/java/codeview/main/controller/MemberControllerTest.java @@ -0,0 +1,109 @@ +package codeview.main.controller; + +import codeview.main.entity.Member; +import codeview.main.service.MemberService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class MemberControllerTest { + + @Mock + private MemberService memberService; + + @Mock + private Authentication authentication; + + @InjectMocks + private MemberController memberController; + + private Member member; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + member = Member.builder() + .email("test@example.com") + .name("SoEun") + .profile("Profile Image") + .build(); + } + + @Test + void testGetUserInfo_Success() { + when(authentication.getName()).thenReturn(member.getEmail()); + when(memberService.findByEmail(anyString())).thenReturn(Optional.of(member)); + + ResponseEntity response = memberController.getUserInfo(authentication); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(member, response.getBody()); + } + + @Test + void testGetUserInfo_NotFound() { + when(authentication.getName()).thenReturn(member.getEmail()); + when(memberService.findByEmail(anyString())).thenReturn(Optional.empty()); + + ResponseEntity response = memberController.getUserInfo(authentication); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + } + + @Test + void testUpdateUserInfo_Success() { + when(authentication.getName()).thenReturn(member.getEmail()); + when(memberService.findByEmail(anyString())).thenReturn(Optional.of(member)); + when(memberService.save(any(Member.class))).thenReturn(member); + + Member updatedMember = Member.builder().name("Jane Doe").profile("Updated Profile").build(); + ResponseEntity response = memberController.updateUserInfo(authentication, updatedMember); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("Jane Doe", response.getBody().getName()); + assertEquals("Updated Profile", response.getBody().getProfile()); + } + + @Test + void testUpdateUserInfo_NotFound() { + when(authentication.getName()).thenReturn(member.getEmail()); + when(memberService.findByEmail(anyString())).thenReturn(Optional.empty()); + + Member updatedMember = Member.builder().name("Jane Doe").build(); + ResponseEntity response = memberController.updateUserInfo(authentication, updatedMember); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + } + + @Test + void testDeleteUser_Success() { + when(authentication.getName()).thenReturn(member.getEmail()); + when(memberService.findByEmail(anyString())).thenReturn(Optional.of(member)); + + ResponseEntity response = memberController.deleteUser(authentication); + + verify(memberService, times(1)).deleteByEmail(anyString()); + assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode()); + } + + @Test + void testDeleteUser_NotFound() { + when(authentication.getName()).thenReturn(member.getEmail()); + when(memberService.findByEmail(anyString())).thenReturn(Optional.empty()); + + ResponseEntity response = memberController.deleteUser(authentication); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + } +} diff --git a/src/test/java/codeview/main/service/BoardServiceTest.java b/src/test/java/codeview/main/service/BoardServiceTest.java new file mode 100644 index 0000000..adc516a --- /dev/null +++ b/src/test/java/codeview/main/service/BoardServiceTest.java @@ -0,0 +1,39 @@ +//package codeview.main.service; +// +//import codeview.main.dto.BoardDTO; +//import codeview.main.entity.Board; +//import codeview.main.repository.BoardRepository; +//import codeview.main.security.service.BoardService; +//import org.junit.jupiter.api.Test; +//import org.junit.jupiter.api.extension.ExtendWith; +//import org.mockito.InjectMocks; +//import org.mockito.Mock; +//import org.mockito.junit.jupiter.MockitoExtension; +// +//import static org.mockito.ArgumentMatchers.any; +//import static org.mockito.Mockito.*; +// +//@ExtendWith(MockitoExtension.class) +//public class BoardServiceTest { +// +// @Mock +// private BoardRepository boardRepository; +// +// @InjectMocks +// private BoardService boardService; +// +// @Test +// public void testSaveBoard() { +// BoardDTO boardDTO = new BoardDTO(); +// boardDTO.setTitle("Test Title"); +// +// Board board = new Board(); +// board.setTitle(boardDTO.getTitle()); +// +// when(boardRepository.save(any(Board.class))).thenReturn(board); +// +// boardService.save(boardDTO); +// +// verify(boardRepository, times(1)).save(any(Board.class)); +// } +//}