diff --git a/.gitignore b/.gitignore index c46dead..df991c9 100644 --- a/.gitignore +++ b/.gitignore @@ -86,7 +86,6 @@ fabric.properties .idea/* -!.idea/codeStyles !.idea/runConfigurations ### Java ### @@ -175,4 +174,7 @@ gradle-app.setting # Java heap dump *.hprof -# End of https://www.toptal.com/developers/gitignore/api/java,gradle,macos,intellij+all \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/java,gradle,macos,intellij+allauth.yml +oauth.yml +database.yml +jwt.yml diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..9a295ca --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index d391089..3827b7a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,40 +1,47 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.3.2' - id 'io.spring.dependency-management' version '1.1.6' + id 'java' + id 'org.springframework.boot' version '3.3.2' + id 'io.spring.dependency-management' version '1.1.6' } group = 'kaboo' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + 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-web' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // JWT Token Dependency + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/src/main/java/kaboo/kaboo_auth/config/PasswordEncoderConfig.java b/src/main/java/kaboo/kaboo_auth/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..7f6c502 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/config/PasswordEncoderConfig.java @@ -0,0 +1,14 @@ +package kaboo.kaboo_auth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/kaboo/kaboo_auth/config/SecurityConfig.java b/src/main/java/kaboo/kaboo_auth/config/SecurityConfig.java new file mode 100644 index 0000000..6202af5 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/config/SecurityConfig.java @@ -0,0 +1,63 @@ +package kaboo.kaboo_auth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.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.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import kaboo.kaboo_auth.domain.handler.LoginSuccessHandler; +import kaboo.kaboo_auth.domain.jwt.filter.JwtFilter; +import kaboo.kaboo_auth.service.CustomOAuth2Service; +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtFilter jwtFilter; + private final CustomOAuth2Service customOAuth2Service; + private final LoginSuccessHandler loginSuccessHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth + .requestMatchers("/").permitAll() + .anyRequest().authenticated()) // 그 외 요청은 인증 필요 + .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)); + + http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + http.oauth2Login(auth -> auth + .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOAuth2Service)) + .successHandler(loginSuccessHandler)); + + return http.build(); + } + + @Bean + public CorsFilter corsFilter() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedOriginPattern("*"); // 모든 도메인 허용. 필요에 따라 변경 + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } +} diff --git a/src/main/java/kaboo/kaboo_auth/controller/MainController.java b/src/main/java/kaboo/kaboo_auth/controller/MainController.java new file mode 100644 index 0000000..2665bb2 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/controller/MainController.java @@ -0,0 +1,24 @@ +package kaboo.kaboo_auth.controller; + +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class MainController { + + @GetMapping("/") + public String mainAPI() { + return "누구나 접근가능한 API입니다."; + } + + @GetMapping("/test") + public String authAPI() { + return "권한 Test API 입니다."; + } + + @GetMapping("/auth/hello") + public String helloAuth(Authentication authentication) { + return "인가 받은 사용자 " + authentication.getName() + " 님 환영합니다."; + } +} diff --git a/src/main/java/kaboo/kaboo_auth/controller/MainControllerAdvice.java b/src/main/java/kaboo/kaboo_auth/controller/MainControllerAdvice.java new file mode 100644 index 0000000..0d62ae9 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/controller/MainControllerAdvice.java @@ -0,0 +1,26 @@ +package kaboo.kaboo_auth.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import kaboo.kaboo_auth.domain.dto.response.ResponseDTO; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestControllerAdvice +public class MainControllerAdvice { + + @ExceptionHandler({IllegalStateException.class, UsernameNotFoundException.class}) + public ResponseEntity> exceptionHandler(Exception e) { + log.error("[Kaboo-Auth]: 예외가 발생하였습니다. {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.OK) + .body(new ResponseDTO<>( + true, + e.getMessage(), + null + )); + } +} diff --git a/src/main/java/kaboo/kaboo_auth/controller/MemberController.java b/src/main/java/kaboo/kaboo_auth/controller/MemberController.java new file mode 100644 index 0000000..4affd6e --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/controller/MemberController.java @@ -0,0 +1,86 @@ +package kaboo.kaboo_auth.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import kaboo.kaboo_auth.domain.dto.request.MemberInfoUpdateRequest; +import kaboo.kaboo_auth.domain.dto.response.MemberInfoResponse; +import kaboo.kaboo_auth.domain.dto.response.MemberListResponse; +import kaboo.kaboo_auth.domain.dto.response.ResponseDTO; +import kaboo.kaboo_auth.service.MemberService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/auth/member") +public class MemberController { + + private final MemberService memberService; + + @GetMapping("/all") + public ResponseEntity> getAllMembers() { + return ResponseEntity.status(HttpStatus.OK) + .body(new ResponseDTO<>( + memberService.getAllMembers() + )); + } + + @GetMapping("/class/{class}") + public ResponseEntity> getClassMembers( + @PathVariable(name = "class") int classNum) { + + return ResponseEntity.status(HttpStatus.OK) + .body(new ResponseDTO<>( + memberService.getMembersByClassNum(classNum) + )); + } + + @GetMapping + public ResponseEntity> getMemberInfo( + @RequestParam(name = "name", defaultValue = "") String koreaName) { + + return ResponseEntity.status(HttpStatus.OK) + .body(new ResponseDTO<>( + memberService.getMemberInfoByKoreaName(koreaName) + )); + } + + @PostMapping + public ResponseEntity> updateMemberInfo( + @RequestParam(name = "name", defaultValue = "") String koreaName, + @RequestBody MemberInfoUpdateRequest request) { + + return ResponseEntity.status(HttpStatus.OK) + .body(new ResponseDTO<>( + memberService.updateMemberInfoByKoreaName(koreaName, request) + )); + } + + @GetMapping("/introduce") + public ResponseEntity> getMemberIntroduce( + @RequestParam(name = "name", defaultValue = "") String koreaName) { + + return ResponseEntity.status(HttpStatus.OK) + .body(new ResponseDTO<>( + memberService.getMemberIntroduceByKoreaName(koreaName) + )); + } + + @PostMapping("/introduce") + public ResponseEntity> updateMemberIntrouce( + @RequestParam(name = "name", defaultValue = "") String koreaName, + @RequestBody String request) { + + return ResponseEntity.status(HttpStatus.OK) + .body(new ResponseDTO<>( + memberService.updateMemberIntroduceByKoreaName(koreaName, request) + )); + } +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/Course.java b/src/main/java/kaboo/kaboo_auth/domain/Course.java new file mode 100644 index 0000000..c5a3a06 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/Course.java @@ -0,0 +1,13 @@ +package kaboo.kaboo_auth.domain; + +public enum Course { + AI("GenAI"), + FULLSTACK("Fullstack"), + CLOUD("Cloud"); + + private final String course; + + Course(String course) { + this.course = course; + } +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/CustomUserDetails.java b/src/main/java/kaboo/kaboo_auth/domain/CustomUserDetails.java new file mode 100644 index 0000000..7554366 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/CustomUserDetails.java @@ -0,0 +1,45 @@ +package kaboo.kaboo_auth.domain; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import kaboo.kaboo_auth.domain.entity.Member; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CustomUserDetails implements OAuth2User, UserDetails { + private final Member member; + + @Override + public String getUsername() { + return member.getUsername(); + } + + @Override + public String getName() { + return member.getKoreaName(); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public Map getAttributes() { + return null; + } + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + collection.add((GrantedAuthority)() -> member.getRole().toString()); + + return collection; + } +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/UserRole.java b/src/main/java/kaboo/kaboo_auth/domain/UserRole.java new file mode 100644 index 0000000..960c830 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/UserRole.java @@ -0,0 +1,15 @@ +package kaboo.kaboo_auth.domain; + +import lombok.Getter; + +@Getter +public enum UserRole { + ROLE_ADMIN("ROLE_ADMIN"), + ROLE_USER("ROLE_USER"); + + private final String role; + + UserRole(String role) { + this.role = role; + } +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/dto/request/MemberInfoUpdateRequest.java b/src/main/java/kaboo/kaboo_auth/domain/dto/request/MemberInfoUpdateRequest.java new file mode 100644 index 0000000..53ab147 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/dto/request/MemberInfoUpdateRequest.java @@ -0,0 +1,13 @@ +package kaboo.kaboo_auth.domain.dto.request; + +import kaboo.kaboo_auth.domain.Course; +import lombok.Getter; + +@Getter +public class MemberInfoUpdateRequest { + private String koreaName; + private String englishName; + private int classNum; + private Course course; + +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/dto/response/KakaoResponse.java b/src/main/java/kaboo/kaboo_auth/domain/dto/response/KakaoResponse.java new file mode 100644 index 0000000..d7bc93e --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/dto/response/KakaoResponse.java @@ -0,0 +1,72 @@ +package kaboo.kaboo_auth.domain.dto.response; + +import java.util.Map; + +/** Kakao Response 형태 + * { + * id: int, + * connected_at: Date + * properties: { + * nickname: String + * profile_image: URL + * thumbnail_image: URL + * }, + * kakao_account: { + * profile_nickname_needs_agreement: boolean, + * profile_image_needs_agreement: boolean + * profile: { + * nickname: String + * thumbnail_image_url: URL + * profile_image_url: URL + * is_default_image: boolean + * is_default_nickname: boolean + * }, + * has_email=true, + * email_needs_agreement=false, + * is_email_valid=true, + * is_email_verified=true, + * email=taejin7824@kakao.com + * } + * + * } + */ + +public class KakaoResponse implements OAuth2Response { + private final Map attribute; + + public KakaoResponse(Map attribute) { + this.attribute = attribute; + } + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getProviderId() { + return attribute.get("id").toString(); + } + + @Override + public String getEmail() { + Map kakaoAccount = (Map)attribute.get("kakao_account"); + + // "kakao_account" 맵에서 "email" 키의 값을 가져옴 + if (kakaoAccount != null && kakaoAccount.containsKey("email")) { + return kakaoAccount.get("email").toString(); + } + return null; // email이 없을 경우 + } + + @Override + public String getNickname() { + Map properties = (Map)attribute.get("properties"); + + // "properties" 맵에서 "nickname" 키의 값을 가져옴 + if (properties != null && properties.containsKey("nickname")) { + return properties.get("nickname").toString(); + } + return null; // nickname이 없을 경우 + } +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/dto/response/MemberInfoResponse.java b/src/main/java/kaboo/kaboo_auth/domain/dto/response/MemberInfoResponse.java new file mode 100644 index 0000000..853943c --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/dto/response/MemberInfoResponse.java @@ -0,0 +1,20 @@ +package kaboo.kaboo_auth.domain.dto.response; + +import kaboo.kaboo_auth.domain.Course; +import kaboo.kaboo_auth.domain.entity.Member; +import lombok.Getter; + +@Getter +public class MemberInfoResponse { + private final String koreaName; + private final String englishName; + private final int classNum; + private final Course course; + + public MemberInfoResponse(Member member) { + this.koreaName = member.getKoreaName(); + this.englishName = member.getEnglishName(); + this.classNum = member.getClassNum(); + this.course = member.getCourse(); + } +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/dto/response/MemberListResponse.java b/src/main/java/kaboo/kaboo_auth/domain/dto/response/MemberListResponse.java new file mode 100644 index 0000000..2f46aff --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/dto/response/MemberListResponse.java @@ -0,0 +1,21 @@ +package kaboo.kaboo_auth.domain.dto.response; + +import java.util.List; + +import kaboo.kaboo_auth.domain.entity.Member; +import lombok.Getter; + +@Getter +public class MemberListResponse { + private final int classNum; + private final int memberNum; + private final List memberList; + + public MemberListResponse(List members, int classNum) { + this.classNum = classNum; + this.memberNum = members.size(); + this.memberList = members.stream() + .map(MemberInfoResponse::new) + .toList(); + } +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/dto/response/OAuth2Response.java b/src/main/java/kaboo/kaboo_auth/domain/dto/response/OAuth2Response.java new file mode 100644 index 0000000..145d7ec --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/dto/response/OAuth2Response.java @@ -0,0 +1,11 @@ +package kaboo.kaboo_auth.domain.dto.response; + +public interface OAuth2Response { + String getProvider(); + + String getProviderId(); + + String getEmail(); + + String getNickname(); +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/dto/response/ResponseDTO.java b/src/main/java/kaboo/kaboo_auth/domain/dto/response/ResponseDTO.java new file mode 100644 index 0000000..a292021 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/dto/response/ResponseDTO.java @@ -0,0 +1,18 @@ +package kaboo.kaboo_auth.domain.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ResponseDTO { + private boolean success; + private String message; + private T data; + + public ResponseDTO(T data) { + success = true; + message = "요청이 성공적으로 처리되었습니다."; + this.data = data; + } +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/entity/Member.java b/src/main/java/kaboo/kaboo_auth/domain/entity/Member.java new file mode 100644 index 0000000..bbd68c9 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/entity/Member.java @@ -0,0 +1,67 @@ +package kaboo.kaboo_auth.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import kaboo.kaboo_auth.domain.Course; +import kaboo.kaboo_auth.domain.UserRole; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class Member { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + private String username; + private String email; + private String koreaName; + private String englishName; + private String password; + + @Column(columnDefinition = "TEXT") + private String introduce; + + private int classNum; + + @Enumerated(EnumType.STRING) + private Course course; + + @Enumerated(EnumType.STRING) + private UserRole role; + + @Builder + public Member(String username, String email, String koreaName, String englishName, String password, + String introduce, + int classNum, Course course, UserRole role) { + this.username = username; + this.email = email; + this.koreaName = koreaName; + this.englishName = englishName; + this.password = password; + this.introduce = introduce; + this.classNum = classNum; + this.course = course; + this.role = role; + } + + public void updateInfo(String koreaName, String englishName, int classNum, Course course) { + this.koreaName = koreaName; + this.englishName = englishName; + this.classNum = classNum; + this.course = course; + } + + public void updateIntroduce(String introduce) { + this.introduce = introduce; + } +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/entity/ProfileImage.java b/src/main/java/kaboo/kaboo_auth/domain/entity/ProfileImage.java new file mode 100644 index 0000000..590c501 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/entity/ProfileImage.java @@ -0,0 +1,35 @@ +package kaboo.kaboo_auth.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@NoArgsConstructor +public class ProfileImage { + @Id + @Column(name = "profile_image_name") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne + @JoinColumn(name = "member_id") + private Member member; + + @Setter + @Column(name = "image_name") + private String imageName; + + public ProfileImage(Member member, String imageName) { + this.member = member; + this.imageName = imageName; + } +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/handler/LoginSuccessHandler.java b/src/main/java/kaboo/kaboo_auth/domain/handler/LoginSuccessHandler.java new file mode 100644 index 0000000..4bfe757 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/handler/LoginSuccessHandler.java @@ -0,0 +1,54 @@ +package kaboo.kaboo_auth.domain.handler; + +import java.io.IOException; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kaboo.kaboo_auth.domain.jwt.JwtTokenProvider; +import kaboo.kaboo_auth.domain.jwt.entity.JwtAccessToken; +import kaboo.kaboo_auth.domain.jwt.entity.JwtRefreshToken; +import kaboo.kaboo_auth.domain.jwt.repository.JwtAccessTokenRepository; +import kaboo.kaboo_auth.domain.jwt.repository.JwtRefreshTokenRepository; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtAccessTokenRepository jwtAccessTokenRepository; + private final JwtRefreshTokenRepository jwtRefreshTokenRepository; + private final JwtTokenProvider jwtTokenProvider; + private final int accessTokenValidTime = 10 * 60; // 유효기간 : 10분 + private final int refreshTokenValidTime = 10 * 24 * 60 * 60; // 유효기간 : 10일 + + private Cookie createCookie(String key, String value, int maxAge) { + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(maxAge); + cookie.setPath("/"); + cookie.setHttpOnly(true); + + return cookie; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws + IOException { + String username = authentication.getName(); + String accessToken = jwtTokenProvider.createAccessToken(username); + String refreshToken = jwtTokenProvider.createRefreshToken(username); + + jwtAccessTokenRepository.save(new JwtAccessToken(username, accessToken)); + jwtRefreshTokenRepository.save(new JwtRefreshToken(username, refreshToken)); + + response.addCookie(createCookie("Username", username, refreshTokenValidTime)); + response.addCookie(createCookie("Authorization", accessToken, accessTokenValidTime)); + response.addCookie(createCookie("RefreshToken", refreshToken, refreshTokenValidTime)); + response.sendRedirect("/"); + } +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/jwt/JwtTokenProvider.java b/src/main/java/kaboo/kaboo_auth/domain/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..37d6007 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/jwt/JwtTokenProvider.java @@ -0,0 +1,62 @@ +package kaboo.kaboo_auth.domain.jwt; + +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; + +@Component +public class JwtTokenProvider { + + private final String accessSecretKey; + private final String refreshSecretKey; + private final String issuer; + + private final long accessTokenValidTime = 10 * 60 * 1_000L; // 유효기간: 10분 + private final long refreshTokenValidTime = 10 * 24 * 60 * 60 * 1_000L; // 유효기간: 10일 + + public JwtTokenProvider( + @Value("${JWT.ACCESS_SECRET_KEY}") String accessSecretKey, + @Value("${JWT.REFRESH_SECRET_KEY}") String refreshSecretKey, + @Value("${JWT.ISSUER}") String issuer) { + + this.accessSecretKey = accessSecretKey; + this.refreshSecretKey = refreshSecretKey; + this.issuer = issuer; + } + + private SecretKey getSignedKey(String key) { + byte[] keyBytes = Decoders.BASE64.decode(key); + return Keys.hmacShaKeyFor(keyBytes); + } + + public String createAccessToken(String username) { + Date now = new Date(); + + return Jwts.builder() + .subject(username) + .issuer(issuer) + .issuedAt(now) + .expiration(new Date(now.getTime() + accessTokenValidTime)) + .signWith(getSignedKey(accessSecretKey)) + .compact(); + } + + public String createRefreshToken(String username) { + Date now = new Date(); + + return Jwts.builder() + .subject(username) + .issuer(issuer) + .issuedAt(now) + .expiration(new Date(now.getTime() + refreshTokenValidTime)) + .signWith(getSignedKey(refreshSecretKey)) + .compact(); + } +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/jwt/entity/JwtAccessToken.java b/src/main/java/kaboo/kaboo_auth/domain/jwt/entity/JwtAccessToken.java new file mode 100644 index 0000000..fa4daf8 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/jwt/entity/JwtAccessToken.java @@ -0,0 +1,16 @@ +package kaboo.kaboo_auth.domain.jwt.entity; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RedisHash(value = "JwtAccessToken", timeToLive = 60 * 10) // 유효기간 : 10분 +@RequiredArgsConstructor +public class JwtAccessToken { + @Id + private final String username; + private final String accessToken; +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/jwt/entity/JwtRefreshToken.java b/src/main/java/kaboo/kaboo_auth/domain/jwt/entity/JwtRefreshToken.java new file mode 100644 index 0000000..ca61a61 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/jwt/entity/JwtRefreshToken.java @@ -0,0 +1,16 @@ +package kaboo.kaboo_auth.domain.jwt.entity; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RedisHash(value = "JwtRefreshToken", timeToLive = 60 * 60 * 24 * 10) // 유효기간 : 10일 +@RequiredArgsConstructor +public class JwtRefreshToken { + @Id + private final String username; + private final String refreshToken; +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/jwt/filter/JwtFilter.java b/src/main/java/kaboo/kaboo_auth/domain/jwt/filter/JwtFilter.java new file mode 100644 index 0000000..c62d302 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/jwt/filter/JwtFilter.java @@ -0,0 +1,116 @@ +package kaboo.kaboo_auth.domain.jwt.filter; + +import java.io.IOException; +import java.util.Optional; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kaboo.kaboo_auth.domain.CustomUserDetails; +import kaboo.kaboo_auth.domain.entity.Member; +import kaboo.kaboo_auth.domain.jwt.JwtTokenProvider; +import kaboo.kaboo_auth.domain.jwt.entity.JwtAccessToken; +import kaboo.kaboo_auth.domain.jwt.entity.JwtRefreshToken; +import kaboo.kaboo_auth.domain.jwt.repository.JwtAccessTokenRepository; +import kaboo.kaboo_auth.domain.jwt.repository.JwtRefreshTokenRepository; +import kaboo.kaboo_auth.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final MemberRepository memberRepository; + private final JwtAccessTokenRepository jwtAccessTokenRepository; + private final JwtRefreshTokenRepository jwtRefreshTokenRepository; + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + // 쿠키가 없다면 다음 로직으로 + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + chain.doFilter(request, response); + return; + } + + String username = null, cookieAccessToken = null, cookieRefreshToken = null; + for (Cookie cookie : cookies) { + switch (cookie.getName()) { + case "Username" -> username = cookie.getValue(); + case "Authorization" -> cookieAccessToken = cookie.getValue(); + case "RefreshToken" -> cookieRefreshToken = cookie.getValue(); + } + } + + // 쿠키에 토큰이 있는지 검사 + if (cookieAccessToken == null || username == null) { + chain.doFilter(request, response); + return; + } + Optional jwtAccessToken = jwtAccessTokenRepository.findById(username); + + // 요청한 사용자정보로 redis 에 accessToken 이 존재할 때 + if (jwtAccessToken.isPresent()) { + JwtAccessToken accessToken = jwtAccessToken.get(); + + // 토큰에서 꺼낸 사용자 id와 전달 받은 사용자 id가 같고 + // Redis에 저장된 AccessToken과 전달 받은 AccessToken이 일치할 때 + if (accessToken.getUsername().equals(username) && accessToken.getAccessToken() + .equals(cookieAccessToken)) { + Member member = memberRepository.findByUsername(username) // 해당 사용자 id 로 사용자 정보가 있는지 찾기 + .orElseThrow(() -> { + // 로그인에 성공한 사람들(사용자 정보가 있는 경우)만 토큰을 부여받기 때문에 예외를 던진다면 데이터베이스 오류이거나 로그인 로직에 버그가 있는 것 + return new UsernameNotFoundException("해당 사용자 정보를 찾을 수 없습니다. [DB OR 로그인 로직 버그]"); + }); + + CustomUserDetails userDetails = new CustomUserDetails(member); + Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, + userDetails.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + chain.doFilter(request, response); + + return; + } + } + + if (cookieRefreshToken != null) { // 리프레시 토큰이 전달되었다면 + Optional jwtRefreshToken = jwtRefreshTokenRepository.findById(username); + if (jwtRefreshToken.isPresent()) { // Redis에 리프레시 토큰이 존재한다면 + JwtRefreshToken refreshToken = jwtRefreshToken.get(); + + // 토큰에서 꺼낸 사용자 id와 전달 받은 사용자 id가 같고 + // Redis에 저장된 AccessToken과 전달 받은 AccessToken이 일치할 때 + if (refreshToken.getUsername().equals(username) && refreshToken.getRefreshToken() + .equals(cookieRefreshToken)) { + JwtAccessToken newAccessToken = new JwtAccessToken(username, + jwtTokenProvider.createAccessToken(username)); + + jwtAccessTokenRepository.save(newAccessToken); + response.addCookie(createCookie("Authorization", newAccessToken.getAccessToken())); + } + } + } + } + + private Cookie createCookie(String key, String value) { + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(10 * 60); + cookie.setPath("/"); + cookie.setHttpOnly(true); + return cookie; + } +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/jwt/repository/JwtAccessTokenRepository.java b/src/main/java/kaboo/kaboo_auth/domain/jwt/repository/JwtAccessTokenRepository.java new file mode 100644 index 0000000..4b9328d --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/jwt/repository/JwtAccessTokenRepository.java @@ -0,0 +1,8 @@ +package kaboo.kaboo_auth.domain.jwt.repository; + +import org.springframework.data.repository.CrudRepository; + +import kaboo.kaboo_auth.domain.jwt.entity.JwtAccessToken; + +public interface JwtAccessTokenRepository extends CrudRepository { +} diff --git a/src/main/java/kaboo/kaboo_auth/domain/jwt/repository/JwtRefreshTokenRepository.java b/src/main/java/kaboo/kaboo_auth/domain/jwt/repository/JwtRefreshTokenRepository.java new file mode 100644 index 0000000..7411367 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/domain/jwt/repository/JwtRefreshTokenRepository.java @@ -0,0 +1,8 @@ +package kaboo.kaboo_auth.domain.jwt.repository; + +import org.springframework.data.repository.CrudRepository; + +import kaboo.kaboo_auth.domain.jwt.entity.JwtRefreshToken; + +public interface JwtRefreshTokenRepository extends CrudRepository { +} diff --git a/src/main/java/kaboo/kaboo_auth/repository/MemberRepository.java b/src/main/java/kaboo/kaboo_auth/repository/MemberRepository.java new file mode 100644 index 0000000..22b742d --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/repository/MemberRepository.java @@ -0,0 +1,16 @@ +package kaboo.kaboo_auth.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import kaboo.kaboo_auth.domain.entity.Member; + +public interface MemberRepository extends JpaRepository { + Optional findByUsername(String username); + + List findByClassNum(int classNum); + + Optional findByKoreaName(String koreaName); +} diff --git a/src/main/java/kaboo/kaboo_auth/repository/ProfileImageRepository.java b/src/main/java/kaboo/kaboo_auth/repository/ProfileImageRepository.java new file mode 100644 index 0000000..d0e6c46 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/repository/ProfileImageRepository.java @@ -0,0 +1,8 @@ +package kaboo.kaboo_auth.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import kaboo.kaboo_auth.domain.entity.ProfileImage; + +public interface ProfileImageRepository extends JpaRepository { +} diff --git a/src/main/java/kaboo/kaboo_auth/service/CustomOAuth2Service.java b/src/main/java/kaboo/kaboo_auth/service/CustomOAuth2Service.java new file mode 100644 index 0000000..87eb4b4 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/service/CustomOAuth2Service.java @@ -0,0 +1,67 @@ +package kaboo.kaboo_auth.service; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import kaboo.kaboo_auth.domain.CustomUserDetails; +import kaboo.kaboo_auth.domain.UserRole; +import kaboo.kaboo_auth.domain.dto.response.KakaoResponse; +import kaboo.kaboo_auth.domain.dto.response.OAuth2Response; +import kaboo.kaboo_auth.domain.entity.Member; +import kaboo.kaboo_auth.repository.MemberRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2Service extends DefaultOAuth2UserService { + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Value("${AUTH.PASSWORD_POSTFIX}") + String passwordPostfix; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oauth2User = super.loadUser(userRequest); + OAuth2Response response; + if (userRequest.getClientRegistration().getRegistrationId().equals("kakao")) { + response = new KakaoResponse(oauth2User.getAttributes()); + } else { + throw new IllegalArgumentException("사용할 수 없는 인증방법입니다."); + } + + String provider = response.getProvider(); + String providerId = response.getProviderId(); + + // 중복이 발생하지 않도록 provider와 providerId를 조합 + String username = provider + "_" + providerId; + String email = response.getEmail(); + String nickname = response.getNickname(); + + Optional byUsername = memberRepository.findByUsername(username); + Member member = null; + if (byUsername.isEmpty()) { + String rawPassword = username + passwordPostfix; + member = Member.builder() + .username(username) + .koreaName(nickname) + .email(email) + .password(passwordEncoder.encode(rawPassword)) + .role(UserRole.ROLE_USER) + .build(); + + memberRepository.save(member); + } else { + member = byUsername.get(); + } + + return new CustomUserDetails(member); + } +} diff --git a/src/main/java/kaboo/kaboo_auth/service/MemberService.java b/src/main/java/kaboo/kaboo_auth/service/MemberService.java new file mode 100644 index 0000000..ac0b6b6 --- /dev/null +++ b/src/main/java/kaboo/kaboo_auth/service/MemberService.java @@ -0,0 +1,66 @@ +package kaboo.kaboo_auth.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import kaboo.kaboo_auth.domain.dto.request.MemberInfoUpdateRequest; +import kaboo.kaboo_auth.domain.dto.response.MemberInfoResponse; +import kaboo.kaboo_auth.domain.dto.response.MemberListResponse; +import kaboo.kaboo_auth.domain.entity.Member; +import kaboo.kaboo_auth.repository.MemberRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MemberService { + private final MemberRepository memberRepository; + + public Member getMemberByUsername(String username) { + return memberRepository.findByUsername(username).orElseThrow(() -> + new IllegalStateException("존재하지 않는 ID 입니다.") + ); + } + + public MemberListResponse getAllMembers() { + List members = memberRepository.findAll(); + return new MemberListResponse(members, 0); + } + + public MemberListResponse getMembersByClassNum(int classNum) { + List members = memberRepository.findByClassNum(classNum); + return new MemberListResponse(members, classNum); + } + + public MemberInfoResponse getMemberInfoByKoreaName(String koreaName) { + return new MemberInfoResponse( + getMember(koreaName)); + } + + @Transactional + public MemberInfoResponse updateMemberInfoByKoreaName(String koreaName, MemberInfoUpdateRequest request) { + Member member = getMember(koreaName); + + member.updateInfo(request.getKoreaName(), request.getEnglishName(), request.getClassNum(), request.getCourse()); + + return new MemberInfoResponse(member); + } + + public String getMemberIntroduceByKoreaName(String koreaName) { + return getMember(koreaName).getIntroduce(); + } + + @Transactional + public String updateMemberIntroduceByKoreaName(String koreaName, String request) { + Member member = getMember(koreaName); + member.updateIntroduce(request); + + return member.getIntroduce(); + } + + private Member getMember(String koreaName) { + return memberRepository.findByKoreaName(koreaName) + .orElseThrow(() -> new IllegalStateException(koreaName + "을 찾을 수 없습니다. 다시 한번 확인해주세요.")); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 84dcfeb..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=kaboo-auth diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..8c2d5c5 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,8 @@ +spring: + application: + name: kaboo-auth + config: + import: + - oauth.yml + - jwt.yml + - database.yml diff --git a/src/test/java/kaboo/kaboo_auth/KabooAuthApplicationTests.java b/src/test/java/kaboo/kaboo_auth/KabooAuthApplicationTests.java index 70093fc..af9cdf9 100644 --- a/src/test/java/kaboo/kaboo_auth/KabooAuthApplicationTests.java +++ b/src/test/java/kaboo/kaboo_auth/KabooAuthApplicationTests.java @@ -4,6 +4,7 @@ import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest +@ActiveProfiles("test") class KabooAuthApplicationTests { @Test diff --git a/src/test/java/kaboo/kaboo_auth/repository/MemberRepositoryTest.java b/src/test/java/kaboo/kaboo_auth/repository/MemberRepositoryTest.java new file mode 100644 index 0000000..b1c8d0f --- /dev/null +++ b/src/test/java/kaboo/kaboo_auth/repository/MemberRepositoryTest.java @@ -0,0 +1,61 @@ +package kaboo.kaboo_auth.repository; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.transaction.annotation.Transactional; + +import kaboo.kaboo_auth.domain.entity.Member; + +@DataJpaTest +@Transactional +@DisplayName("Member Repository Test") +class MemberRepositoryTest { + @Autowired + MemberRepository memberRepository; + + @Test + @DisplayName("DB 저장 Test") + void saveMemberTest() { + // Given + Member member = Member.builder().username("Alice").englishName("Alice").password("1234").build(); + memberRepository.save(member); + + // When + Member result = memberRepository.findById(member.getId()).get(); + + // Then + assertEquals(result, member); + } + + @Test + @DisplayName("Username으로 찾기 성공 Test") + void findByUsername_Success() { + // Given + Member member1 = Member.builder().username("Alice").englishName("Alice").password("1234").build(); + memberRepository.save(member1); + + // When + Member result = memberRepository.findByUsername("Alice").get(); + + // Then + assertEquals(result, member1); + } + + @Test + @DisplayName("Username 존재하지 않을 때 Test") + void findByUsername_Failure() { + // Given + + // When + Optional result = memberRepository.findByUsername("Alice"); + + // Then + assertEquals(result, Optional.empty()); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..01468af --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,18 @@ +spring: + datasource: + url: jdbc:h2:mem:test;MODE=MariaDB;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + driverClassName: org.h2.Driver + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + h2: + console: + enabled: true + path: /h2-console \ No newline at end of file