diff --git a/build.gradle b/build.gradle index 090b44d..013ea77 100644 --- a/build.gradle +++ b/build.gradle @@ -22,14 +22,6 @@ configurations { repositories { mavenCentral() - maven { - name = 'chuseok22NexusRelease' - url = uri('https://nexus.chuseok22.com/repository/maven-releases/') - metadataSources { - mavenPom() - artifact() - } - } } dependencies { @@ -40,9 +32,16 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j:8.0.33' - implementation 'io.jsonwebtoken:jjwt-api:0.12.3' - implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' - implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + //Http + implementation 'com.squareup.okhttp3:okhttp:4.9.3' + implementation 'org.jsoup:jsoup:1.15.3' + + //JavaNetCookieJar + implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.3' + //HttpLogginInterceptor + implementation 'com.squareup.okhttp3:logging-interceptor:4.9.3' + annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' @@ -57,16 +56,10 @@ dependencies { implementation 'org.springframework.cloud:spring-cloud-starter-aws-messaging:2.2.6.RELEASE' - implementation 'com.squareup.okhttp3:okhttp:4.11.0' - implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.11.0' // 추가 - // Jsoup (HTML 파싱) - implementation 'org.jsoup:jsoup:1.18.1' - developmentOnly 'org.springframework.boot:spring-boot-devtools' - annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - implementation 'com.chuseok22:sejong-portal-login:1.0.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" implementation 'org.apache.poi:poi-ooxml:5.2.3' diff --git a/src/main/java/com/example/enjoy/controller/LoginController.java b/src/main/java/com/example/enjoy/controller/LoginController.java index c732c98..b2b3dff 100644 --- a/src/main/java/com/example/enjoy/controller/LoginController.java +++ b/src/main/java/com/example/enjoy/controller/LoginController.java @@ -2,7 +2,8 @@ import com.example.enjoy.dto.loginDto.MemberCommand; import com.example.enjoy.dto.loginDto.MemberDto; -import com.example.enjoy.service.loginService.SejongLoginService; +import com.example.enjoy.dto.loginDto.SejongMemberInfo; +import com.example.enjoy.service.loginService.SejongPortalLoginService; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.AllArgsConstructor; @@ -19,23 +20,16 @@ @AllArgsConstructor @RequestMapping("/api/auth/sejong") public class LoginController { - private final SejongLoginService sejongLoginService; + private final SejongPortalLoginService sejongLoginService; /** * 세종대학교 포털 로그인 및 사용자 정보 조회 * 포털 로그인 -> 고전독서 사이트 SSO 인증 -> 사용자 정보 파싱 및 반환 */ - @PostMapping("/login") - public ResponseEntity loginAndGetUserInfo(@RequestBody @Valid MemberCommand command) { - try { - log.info("세종대 포털 로그인 요청: {}", command.getSejongPortalId()); - MemberDto memberInfo = sejongLoginService.login(command); - log.info("사용자 정보 조회 성공: {}", memberInfo.getStudentName()); - return ResponseEntity.ok(memberInfo); - } catch (Exception e) { - log.error("세종대 포털 로그인 및 정보 조회 실패: {}", e.getMessage(), e); - throw new RuntimeException("세종대 포털 인증 실패", e); - } + @PostMapping + public ResponseEntity login(@RequestBody MemberCommand command) { + log.info("세종대 포털 로그인 시도: {}", command.getSejongPortalId()); + return ResponseEntity.ok(sejongLoginService.getMemberAuthInfos(command)); } } diff --git a/src/main/java/com/example/enjoy/controller/UserController.java b/src/main/java/com/example/enjoy/controller/UserController.java index e0ed2d3..d9a8f03 100644 --- a/src/main/java/com/example/enjoy/controller/UserController.java +++ b/src/main/java/com/example/enjoy/controller/UserController.java @@ -5,9 +5,10 @@ import com.example.enjoy.dto.StudentCourseStatus; import com.example.enjoy.dto.loginDto.MemberCommand; import com.example.enjoy.dto.loginDto.MemberDto; +import com.example.enjoy.dto.loginDto.SejongMemberInfo; import com.example.enjoy.entity.StudentCourse; import com.example.enjoy.entity.Track; -import com.example.enjoy.service.loginService.SejongLoginService; +import com.example.enjoy.service.loginService.SejongPortalLoginService; import com.example.enjoy.service.userService.UserService; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; @@ -23,10 +24,10 @@ @RequestMapping("/api/student") public class UserController { - private final SejongLoginService sejongLoginService; + private final SejongPortalLoginService sejongLoginService; private final UserService userService; - public UserController(SejongLoginService sejongLoginService, UserService userService) { + public UserController(SejongPortalLoginService sejongLoginService, UserService userService) { this.sejongLoginService = sejongLoginService; this.userService = userService; } @@ -34,8 +35,9 @@ public UserController(SejongLoginService sejongLoginService, UserService userSer @Operation(summary = "학생 정보 조회", description = "세종대학교 포털 인증을 통해 학생 정보를 조회합니다.") @PostMapping("/detail") public ResponseEntity getStudentDetail(@RequestBody MemberCommand command) throws IOException { - MemberDto memberInfo = sejongLoginService.getMemberAuthInfos(command); - return ResponseEntity.ok(memberInfo); + SejongMemberInfo memberInfo = sejongLoginService.getMemberAuthInfos(command); + MemberDto dto = MemberDto.fromSejongMemberInfo(memberInfo); + return ResponseEntity.ok(dto); } @Operation(summary = "수동 과목 등록", description = "학생이 직접 수강한 과목을 등록합니다.") diff --git a/src/main/java/com/example/enjoy/dto/loginDto/MemberDto.java b/src/main/java/com/example/enjoy/dto/loginDto/MemberDto.java index 933c2ad..bb0faed 100644 --- a/src/main/java/com/example/enjoy/dto/loginDto/MemberDto.java +++ b/src/main/java/com/example/enjoy/dto/loginDto/MemberDto.java @@ -12,4 +12,15 @@ public class MemberDto { private String grade; private String completedSemester; private boolean hasLoginHistory; // 로그인 이력 여부 + + public static MemberDto fromSejongMemberInfo(SejongMemberInfo sejongMemberInfo){ + return new MemberDto( + sejongMemberInfo.getMajor() + , sejongMemberInfo.getStudentId() + , sejongMemberInfo.getName() + , sejongMemberInfo.getGrade() + , sejongMemberInfo.getCompletedSemester() + , false // 기본값으로 false 설정 + ); + } } diff --git a/src/main/java/com/example/enjoy/dto/loginDto/SejongMemberInfo.java b/src/main/java/com/example/enjoy/dto/loginDto/SejongMemberInfo.java new file mode 100644 index 0000000..7393cc7 --- /dev/null +++ b/src/main/java/com/example/enjoy/dto/loginDto/SejongMemberInfo.java @@ -0,0 +1,17 @@ +package com.example.enjoy.dto.loginDto; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@Builder +@Getter +@ToString +public class SejongMemberInfo { + private String major; + private String name; + private String studentId; + private String grade; + private String status; + private String completedSemester; +} diff --git a/src/main/java/com/example/enjoy/service/loginService/SejongLoginService.java b/src/main/java/com/example/enjoy/service/loginService/SejongLoginService.java deleted file mode 100644 index eebf95f..0000000 --- a/src/main/java/com/example/enjoy/service/loginService/SejongLoginService.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.example.enjoy.service.loginService; - - -import com.chuseok22.sejongportallogin.core.SejongMemberInfo; -import com.chuseok22.sejongportallogin.infrastructure.SejongPortalLoginService; -import com.example.enjoy.dto.loginDto.MemberCommand; -import com.example.enjoy.dto.loginDto.MemberDto; -import com.example.enjoy.entity.user.User; -import com.example.enjoy.exception.CustomException; -import com.example.enjoy.exception.ErrorCode; -import com.example.enjoy.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import java.io.IOException; - -@Slf4j -@Service -@RequiredArgsConstructor -public class SejongLoginService { - - private final SejongPortalLoginService sejongPortalLoginService; - private final UserRepository userRepository; - - public MemberDto login(MemberCommand memberCommand){ - boolean hasLoginHistory = userRepository.existsByStudentId(memberCommand.getSejongPortalId()); - SejongMemberInfo info = sejongPortalLoginService.getMemberAuthInfos(memberCommand.getSejongPortalId(), memberCommand.getSejongPortalPassword()); - - // 로그인 성공 시 사용자 정보 저장 (첫 로그인인 경우) - if (!hasLoginHistory) { - User newUser = User.builder() - .major(info.getMajor()) - .studentId(info.getStudentId()) - .username(info.getName()) - .grade(info.getGrade()) - .completedSemester(info.getCompletedSemester()) - .build(); - userRepository.save(newUser); - } - - return MemberDto.builder() - .major(info.getMajor()) - .studentIdString(info.getStudentId()) - .studentName(info.getName()) - .grade(info.getGrade()) - .completedSemester(info.getCompletedSemester()) - .hasLoginHistory(hasLoginHistory) - .build(); - } - - public MemberDto getMemberAuthInfos(MemberCommand memberCommand) throws IOException { - try { - SejongMemberInfo info = sejongPortalLoginService.getMemberAuthInfos(memberCommand.getSejongPortalId(), memberCommand.getSejongPortalPassword()); - return MemberDto.builder() - .major(info.getMajor()) - .studentIdString(info.getStudentId()) - .studentName(info.getName()) - .grade(info.getGrade()) - .completedSemester(info.getCompletedSemester()) - .build(); - } catch (Exception e) { - log.error("세종대학교 포털 로그인 정보 가져오기 실패: {}", e.getMessage()); - throw new CustomException(ErrorCode.SEJONG_AUTH_DATA_FETCH_ERROR); - } - } - - -} - - diff --git a/src/main/java/com/example/enjoy/service/loginService/SejongPortalLoginService.java b/src/main/java/com/example/enjoy/service/loginService/SejongPortalLoginService.java new file mode 100644 index 0000000..33c831d --- /dev/null +++ b/src/main/java/com/example/enjoy/service/loginService/SejongPortalLoginService.java @@ -0,0 +1,225 @@ +package com.example.enjoy.service.loginService; + +import com.example.enjoy.dto.loginDto.MemberCommand; +import com.example.enjoy.dto.loginDto.SejongMemberInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.*; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.springframework.stereotype.Service; + +import javax.net.ssl.*; +import java.io.IOException; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class SejongPortalLoginService { + + public SejongMemberInfo getMemberAuthInfos(MemberCommand memberCommand) { + + // 실제 포털 인증 + try { + // OkHttpClient 생성 + OkHttpClient client = buildClient(); + + // 포털 로그인 요청 + doPortalLogin(client, memberCommand); + + // SSO 리다이렉트 -> 고전독서인증 사이트 + String ssoUrl = "http://classic.sejong.ac.kr/_custom/sejong/sso/sso-return.jsp?returnUrl=https://classic.sejong.ac.kr/classic/index.do"; + Request ssoReq = new Request.Builder().url(ssoUrl).get().build(); + try (Response ssoResp = client.newCall(ssoReq).execute()) { + if (!ssoResp.isSuccessful()) { + throw new RuntimeException("Connection Eroor"); + } + } + + // 고전독서인증현황 페이지 GET + String html = fetchReadingStatusHtml(client); + + // JSoup 파싱 -> MemberDto + return parseHTMLAndGetMemberInfo(html); + + } catch (IOException e) { + log.error("세종포털 인증 중 IOException: {}", e.getMessage()); + throw new RuntimeException("Connection Eroor"); + } + } + + /** + * 세종포털에 ID/PW로 로그인 (POST) + */ + private void doPortalLogin(OkHttpClient client, MemberCommand memberCommand) throws IOException { + String loginUrl = "https://portal.sejong.ac.kr/jsp/login/login_action.jsp"; + + String studentId = memberCommand.getSejongPortalId(); + String password = memberCommand.getSejongPortalPassword(); + + // POST form data + RequestBody formBody = new FormBody.Builder() + .add("mainLogin", "N") + .add("rtUrl", "library.sejong.ac.kr") + .add("id", studentId) + .add("password", password) + .build(); + + // 요청 객체 생성 + Request request = new Request.Builder() + .url(loginUrl) + .post(formBody) + .header("Host", "portal.sejong.ac.kr") + .header("Referer", "https://portal.sejong.ac.kr") + .header("Cookie", "chknos=false") + .build(); + + // 실제 요청 (재시도 로직 포함) + Response response = executeWithRetry(client, request); + + // 응답 바디를 한 번 읽어주고 닫음 (로그인 결과 페이지) + String body = response.body() != null ? response.body().string() : ""; + response.close(); + } + + /** + * 고전독서인증현황 페이지 HTML 반환 + * @param client OkHttpClient + * @return status.do 페이지의 HTML 문자열 + */ + private String fetchReadingStatusHtml(OkHttpClient client) throws IOException { + String finalUrl = "https://classic.sejong.ac.kr/classic/reading/status.do"; + + Request finalReq = new Request.Builder() + .url(finalUrl) + .get() + .build(); + + // GET 요청 + try (Response finalResp = client.newCall(finalReq).execute()) { + if (finalResp.body() == null || finalResp.code() != 200) { + throw new RuntimeException("SEJONG_AUTH_DATA_FETCH_ERROR"); + } + return finalResp.body().string(); + } + } + + + /** + * 요청 실행 시 예외 발생 시 + * 최대 3회 재시도 + */ + private Response executeWithRetry(OkHttpClient client, Request request) throws IOException { + Response response = null; + int tryCount = 0; + while (tryCount < 3) { + try { + response = client.newCall(request).execute(); + if (response.isSuccessful()) { + return response; + } + } catch (SocketTimeoutException e) { + tryCount++; + log.warn("[PortalLogin] Timeout 발생 -> 재시도... ({}회)", tryCount); + } + } + throw new RuntimeException("세종대 API 연결 오류"); + } + + /** + * 고전독서인증현황 페이지 (status.do) 파싱 + * 학과명, 학번, 이름, 학년, 사용자상태 추출 + * + * @param html 고전독서인증현황 페이지 HTML + * @return MemberDto + */ + private SejongMemberInfo parseHTMLAndGetMemberInfo(String html) { + Document doc = Jsoup.parse(html); + + // "사용자 정보" 테이블 tr 추출 + String selector = ".b-con-box:has(h4.b-h4-tit01:contains(사용자 정보)) table.b-board-table tbody tr"; + List rowValues = new ArrayList<>(); + + doc.select(selector).forEach(tr -> { + String value = tr.select("td").text().trim(); + rowValues.add(value); + }); + + String major = getValueFromList(rowValues, 0); // 학과명 + String studentId = getValueFromList(rowValues, 1); // 학번 + String studentName = getValueFromList(rowValues, 2); // 이름 + String year = getValueFromList(rowValues, 3); // 학년 + String status = getValueFromList(rowValues, 4); // 학생 상태 + String completedSemester = getValueFromList(rowValues, 5); // 이수 학기 + + return SejongMemberInfo.builder() + .major(major) + .studentId(studentId) + .name(studentName) + .grade(year) + .status(status) + .completedSemester(completedSemester) + .build(); + } + + /** + * List index 범위 체크 후 값 꺼내기 + */ + private String getValueFromList(List list, int index) { + return list.size() > index ? list.get(index) : null; + } + + private OkHttpClient buildClient() { + try { + // SSLContext 생성, 모든 인증서 신뢰 설정 + SSLContext sslCtx = SSLContext.getInstance("SSL"); + sslCtx.init(null, new TrustManager[]{trustAllManager()}, new java.security.SecureRandom()); + SSLSocketFactory sslFactory = sslCtx.getSocketFactory(); + + // hostnameVerifier: 모든 호스트네임에 대해 OK 처리 + HostnameVerifier hostnameVerifier = (hostname, session) -> true; + + // 쿠키 관리 + CookieManager cookieManager = new CookieManager(); + cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL); + + // OkHttpClient 생성 + return new OkHttpClient.Builder() + .sslSocketFactory(sslFactory, trustAllManager()) + .hostnameVerifier(hostnameVerifier) + .cookieJar(new JavaNetCookieJar(cookieManager)) + .build(); + + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * 모든 서버 인증서를 신뢰하는 X509TrustManager 구현 + */ + private X509TrustManager trustAllManager() { + return new X509TrustManager() { + @Override + public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) { + } + + @Override + public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) { + } + + @Override + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return new java.security.cert.X509Certificate[0]; + } + }; + } + + + +}