diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/dto/request/LoginRequest.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/dto/request/LoginRequest.kt new file mode 100644 index 0000000..32435b9 --- /dev/null +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/dto/request/LoginRequest.kt @@ -0,0 +1,10 @@ +package com.chuseok22.ctauth.application.dto.request + +import jakarta.validation.constraints.NotBlank + +data class LoginRequest( + @field:NotBlank + val sejongPortalId: String, + @field:NotBlank + val sejongPortalPw: String +) diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/dto/request/ReissueRequest.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/dto/request/ReissueRequest.kt new file mode 100644 index 0000000..c7da225 --- /dev/null +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/dto/request/ReissueRequest.kt @@ -0,0 +1,8 @@ +package com.chuseok22.ctauth.application.dto.request + +import jakarta.validation.constraints.NotBlank + +data class ReissueRequest( + @field:NotBlank + val refreshToken: String +) diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/dto/response/LoginResponse.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/dto/response/LoginResponse.kt new file mode 100644 index 0000000..141ccf8 --- /dev/null +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/dto/response/LoginResponse.kt @@ -0,0 +1,8 @@ +package com.chuseok22.ctauth.application.dto.response + +data class LoginResponse( + val studentNumber: String, + val name: String, + val accessToken: String, + val refreshToken: String +) diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/dto/response/ReissueResponse.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/dto/response/ReissueResponse.kt new file mode 100644 index 0000000..aa25a4b --- /dev/null +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/dto/response/ReissueResponse.kt @@ -0,0 +1,6 @@ +package com.chuseok22.ctauth.application.dto.response + +data class ReissueResponse( + val accessToken: String, + val refreshToken: String +) diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/service/AuthService.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/service/AuthService.kt new file mode 100644 index 0000000..53f1f3c --- /dev/null +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/application/service/AuthService.kt @@ -0,0 +1,89 @@ +package com.chuseok22.ctauth.application.service + +import com.chuseok22.ctauth.application.dto.request.LoginRequest +import com.chuseok22.ctauth.application.dto.request.ReissueRequest +import com.chuseok22.ctauth.application.dto.response.LoginResponse +import com.chuseok22.ctauth.application.dto.response.ReissueResponse +import com.chuseok22.ctauth.core.token.TokenManager +import com.chuseok22.ctauth.core.token.TokenProvider +import com.chuseok22.ctcommon.application.exception.CustomException +import com.chuseok22.ctcommon.application.exception.ErrorCode +import com.chuseok22.ctmember.infrastructure.entity.Member +import com.chuseok22.ctmember.infrastructure.repository.MemberRepository +import com.chuseok22.sejongportallogin.core.SejongMemberInfo +import com.chuseok22.sejongportallogin.infrastructure.SejongPortalLoginService +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +private val log = KotlinLogging.logger { } + +@Service +class AuthService( + private val sejongPortalLoginService: SejongPortalLoginService, + private val memberRepository: MemberRepository, + private val tokenProvider: TokenProvider, + private val tokenManager: TokenManager +) { + + @Transactional + fun login(request: LoginRequest): LoginResponse { + val sejongMemberInfo = sejongPortalLogin(request.sejongPortalId, request.sejongPortalPw) + val studentNumber = sejongMemberInfo.studentId + val name = sejongMemberInfo.name + + + val member = memberRepository.findByStudentNumberAndDeletedFalse(studentNumber) + ?: run { + log.info { "신규 회원 로그인: 학번=$studentNumber, 이름=$name" } + val newMember = Member.create(studentNumber, name) + memberRepository.save(newMember) + } + + // 토큰 발급 + val tokenPair = tokenManager.createTokenPair(member.id.toString()) + tokenManager.saveRefreshTokenTtl(member.id.toString(), tokenPair.refreshToken) + + log.info { "로그인 성공: 학번=$studentNumber, 이름=$name" } + + return LoginResponse( + studentNumber = studentNumber, + name = name, + accessToken = tokenPair.accessToken, + refreshToken = tokenPair.refreshToken + ) + } + + fun reissue(request: ReissueRequest): ReissueResponse { + log.debug { "토큰 재발급을 진행합니다" } + val memberId = tokenProvider.getMemberId(request.refreshToken) + + tokenManager.validateSavedToken(request.refreshToken) + + log.debug { "새로운 accessToken, refreshToken 발급" } + val tokenPair = tokenManager.createTokenPair(memberId) + tokenManager.saveRefreshTokenTtl(memberId, tokenPair.refreshToken) + + return ReissueResponse( + accessToken = tokenPair.accessToken, + refreshToken = tokenPair.refreshToken + ) + } + + fun logout(member: Member) { + val memberId = member.id + log.debug { "로그아웃을 진행합니다: 회원=$memberId" } + log.debug { "기존에 저장된 refreshToken 삭제" } + tokenManager.removeRefreshTokenTtl(memberId.toString()) + } + + private fun sejongPortalLogin(sejongPortalId: String, sejongPortalPw: String): SejongMemberInfo { + try { + log.debug { "세종대학교 포털 로그인을 시도합니다: $sejongPortalId" } + return sejongPortalLoginService.getMemberAuthInfos(sejongPortalId, sejongPortalPw) + } catch (e: Exception) { + log.error(e) { "세종대학교 포털 로그인 중 오류 발생: ${e.message}" } + throw CustomException(ErrorCode.SEJONG_PORTAL_LOGIN_FAILED) + } + } +} \ No newline at end of file diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/core/token/TokenManager.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/core/token/TokenManager.kt new file mode 100644 index 0000000..4837675 --- /dev/null +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/core/token/TokenManager.kt @@ -0,0 +1,24 @@ +package com.chuseok22.ctauth.core.token + +interface TokenManager { + + /** + * accessToken, refreshToken Pair 생성 + */ + fun createTokenPair(memberId: String): TokenPair + + /** + * refreshToken TTL 저장 + */ + fun saveRefreshTokenTtl(memberId: String, refreshToken: String) + + /** + * refreshToken TTL 삭제 + */ + fun removeRefreshTokenTtl(memberId: String) + + /** + * Redis에 저장된 토큰인지 검증 + */ + fun validateSavedToken(token: String) +} \ No newline at end of file diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/core/token/TokenPair.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/core/token/TokenPair.kt new file mode 100644 index 0000000..b90de97 --- /dev/null +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/core/token/TokenPair.kt @@ -0,0 +1,6 @@ +package com.chuseok22.ctauth.core.token + +data class TokenPair( + val accessToken: String, + val refreshToken: String +) diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/core/token/TokenStore.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/core/token/TokenStore.kt index 2ca915d..658ccf5 100644 --- a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/core/token/TokenStore.kt +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/core/token/TokenStore.kt @@ -2,6 +2,11 @@ package com.chuseok22.ctauth.core.token interface TokenStore { + /** + * Redis에 저장된 refreshToken 조회 + */ + fun get(key: String): String? + /** * 리프레시 토큰을 주어진 Key로 저장하고 TTL(ms) 설정 */ diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/constant/SecurityUrls.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/constant/SecurityUrls.kt index e2de9ce..ef2dd53 100644 --- a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/constant/SecurityUrls.kt +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/constant/SecurityUrls.kt @@ -14,5 +14,8 @@ object SecurityUrls { // Swagger "/docs/swagger-ui/**", "/v3/api-docs/**", + + // Health Check + "/actuator/health", ) } \ No newline at end of file diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/jwt/JwtManager.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/jwt/JwtManager.kt new file mode 100644 index 0000000..7608c0c --- /dev/null +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/jwt/JwtManager.kt @@ -0,0 +1,70 @@ +package com.chuseok22.ctauth.infrastructure.jwt + +import com.chuseok22.ctauth.core.token.TokenManager +import com.chuseok22.ctauth.core.token.TokenPair +import com.chuseok22.ctauth.core.token.TokenProvider +import com.chuseok22.ctauth.core.token.TokenStore +import com.chuseok22.ctauth.infrastructure.properties.JwtProperties +import com.chuseok22.ctauth.infrastructure.util.AuthUtil +import com.chuseok22.ctcommon.application.exception.CustomException +import com.chuseok22.ctcommon.application.exception.ErrorCode +import io.github.oshai.kotlinlogging.KotlinLogging +import io.jsonwebtoken.ExpiredJwtException +import org.springframework.stereotype.Component + +private val log = KotlinLogging.logger { } + +@Component +class JwtManager( + private val tokenProvider: TokenProvider, + private val tokenStore: TokenStore, + private val jwtProperties: JwtProperties +) : TokenManager { + + override fun createTokenPair(memberId: String): TokenPair { + return TokenPair( + accessToken = tokenProvider.createAccessToken(memberId), + refreshToken = tokenProvider.createRefreshToken(memberId) + ) + } + + override fun saveRefreshTokenTtl(memberId: String, refreshToken: String) { + log.debug { "Redis에 refreshToken을 저장합니다" } + val key = getKey(memberId) + tokenStore.save(key, refreshToken, jwtProperties.refreshExpMillis) + } + + override fun removeRefreshTokenTtl(memberId: String) { + log.debug { "Redis에 저장된 refreshToken을 삭제합니다: 회원=$memberId" } + val key = getKey(memberId) + tokenStore.remove(key) + } + + override fun validateSavedToken(token: String) { + + val memberId = try { + tokenProvider.getMemberId(token) + } catch (e: ExpiredJwtException) { + log.warn(e) { "만료된 refreshToken 사용 시도" } + throw e + } catch (e: Exception) { + log.warn(e) { "유효하지 않은 refreshToken 사용 시도" } + throw CustomException(ErrorCode.INVALID_JWT) + } + + val key = getKey(memberId) + val savedToken = tokenStore.get(key) ?: run { + log.warn { "Redis에 refreshToken이 존재하지 않습니다: 회원=$memberId" } + throw CustomException(ErrorCode.INVALID_JWT) + } + + if (savedToken != token) { + log.warn { "유효하지 않은 refreshToken 사용 시도: $memberId" } + throw CustomException(ErrorCode.INVALID_JWT) + } + } + + private fun getKey(memberId: String): String { + return AuthUtil.getRefreshTokenTtlKey(memberId) + } +} \ No newline at end of file diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/jwt/JwtProvider.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/jwt/JwtProvider.kt index 860a9e6..b220792 100644 --- a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/jwt/JwtProvider.kt +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/jwt/JwtProvider.kt @@ -24,7 +24,7 @@ class JwtProvider( category = AuthConstants.ACCESS_TOKEN_CATEGORY, memberId = memberId, expMillis = properties.accessExpMillis - ).also { log.info { "엑세스 토큰 생성완료: memberId = $memberId" } } + ).also { log.debug { "엑세스 토큰 생성완료: memberId = $memberId" } } } override fun createRefreshToken(memberId: String): String { @@ -32,7 +32,7 @@ class JwtProvider( category = AuthConstants.REFRESH_TOKEN_CATEGORY, memberId = memberId, expMillis = properties.refreshExpMillis - ).also { log.info { "리프레시 토큰 생성완료: memberId = $memberId" } } + ).also { log.debug { "리프레시 토큰 생성완료: memberId = $memberId" } } } override fun isValidToken(token: String): Boolean { diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/jwt/JwtStore.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/jwt/JwtStore.kt index 9f394e9..f90f99e 100644 --- a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/jwt/JwtStore.kt +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/jwt/JwtStore.kt @@ -7,6 +7,11 @@ import java.util.concurrent.TimeUnit class JwtStore( private val redisTemplate: RedisTemplate ) : TokenStore { + + override fun get(key: String): String? { + return redisTemplate.opsForValue().get(key) + } + override fun save(key: String, refreshToken: String, ttlMillis: Long) { redisTemplate.opsForValue().set(key, refreshToken, ttlMillis, TimeUnit.MILLISECONDS) } diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/user/CustomUserDetails.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/user/CustomUserDetails.kt index 4e2fc82..719600b 100644 --- a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/user/CustomUserDetails.kt +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/user/CustomUserDetails.kt @@ -8,7 +8,7 @@ import org.springframework.security.core.userdetails.UserDetails import java.security.Principal class CustomUserDetails( - private val member: Member + val member: Member ) : UserDetails, UserPrincipal, Principal { override fun getAuthorities(): Collection { return listOf(SimpleGrantedAuthority(member.role.name)) @@ -24,7 +24,7 @@ class CustomUserDetails( } override fun getUsername(): String { - return member.studentName + return member.studentNumber } override fun getRoles(): List { diff --git a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/user/CustomUserDetailsService.kt b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/user/CustomUserDetailsService.kt index 5114c41..bbfb370 100644 --- a/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/user/CustomUserDetailsService.kt +++ b/CT-auth/src/main/kotlin/com/chuseok22/ctauth/infrastructure/user/CustomUserDetailsService.kt @@ -15,7 +15,7 @@ class CustomUserDetailsService( @Transactional(readOnly = true) override fun loadUserByUsername(username: String): UserDetails { - val member = memberRepository.findByStudentNameAndDeletedFalse(username) + val member = memberRepository.findByStudentNumberAndDeletedFalse(username) ?: throw CustomException(ErrorCode.MEMBER_NOT_FOUND) return CustomUserDetails(member) } diff --git a/CT-common/src/main/kotlin/com/chuseok22/ctcommon/application/exception/ErrorCode.kt b/CT-common/src/main/kotlin/com/chuseok22/ctcommon/application/exception/ErrorCode.kt index 32f06fb..3afa20b 100644 --- a/CT-common/src/main/kotlin/com/chuseok22/ctcommon/application/exception/ErrorCode.kt +++ b/CT-common/src/main/kotlin/com/chuseok22/ctcommon/application/exception/ErrorCode.kt @@ -18,4 +18,7 @@ enum class ErrorCode( // Member MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다"), + + // Auth + SEJONG_PORTAL_LOGIN_FAILED(HttpStatus.BAD_REQUEST, "세종대학교 포털 로그인 실패") } \ No newline at end of file diff --git a/CT-member/src/main/kotlin/com/chuseok22/ctmember/infrastructure/entity/Member.kt b/CT-member/src/main/kotlin/com/chuseok22/ctmember/infrastructure/entity/Member.kt index 36a93e5..1662b94 100644 --- a/CT-member/src/main/kotlin/com/chuseok22/ctmember/infrastructure/entity/Member.kt +++ b/CT-member/src/main/kotlin/com/chuseok22/ctmember/infrastructure/entity/Member.kt @@ -13,8 +13,8 @@ open class Member protected constructor() : BaseEntity() { var id: UUID? = null protected set - @field:Column(name = "student_name", nullable = false) - lateinit var studentName: String + @field:Column(name = "student_number", nullable = false) + lateinit var studentNumber: String protected set @field:Column(name = "name", nullable = false) @@ -26,18 +26,18 @@ open class Member protected constructor() : BaseEntity() { var role: Role = Role.ROLE_USER protected set - private constructor(studentName: String, name: String, role: Role) : this() { - this.studentName = normalizeStudentName(studentName) + private constructor(studentNumber: String, name: String, role: Role) : this() { + this.studentNumber = normalizeStudentNumber(studentNumber) this.name = normalizeName(name) this.role = role } companion object { - fun create(studentName: String, name: String): Member { - return Member(studentName, name, Role.ROLE_USER) + fun create(studentNumber: String, name: String): Member { + return Member(studentNumber, name, Role.ROLE_USER) } - private fun normalizeStudentName(raw: String): String { + private fun normalizeStudentNumber(raw: String): String { val normalized: String = raw.trim() require(normalized.isNotBlank()) { "학번은 필수로 입력되어야 합니다 " } return normalized diff --git a/CT-member/src/main/kotlin/com/chuseok22/ctmember/infrastructure/repository/MemberRepository.kt b/CT-member/src/main/kotlin/com/chuseok22/ctmember/infrastructure/repository/MemberRepository.kt index 4a3f569..f58d9ed 100644 --- a/CT-member/src/main/kotlin/com/chuseok22/ctmember/infrastructure/repository/MemberRepository.kt +++ b/CT-member/src/main/kotlin/com/chuseok22/ctmember/infrastructure/repository/MemberRepository.kt @@ -7,5 +7,5 @@ import java.util.* interface MemberRepository : JpaRepository { fun findByIdAndDeletedFalse(memberId: UUID): Member? - fun findByStudentNameAndDeletedFalse(studentName: String): Member? + fun findByStudentNumberAndDeletedFalse(studentNumber: String): Member? } \ No newline at end of file diff --git a/CT-web/src/main/kotlin/com/chuseok22/ctweb/application/controller/auth/AuthController.kt b/CT-web/src/main/kotlin/com/chuseok22/ctweb/application/controller/auth/AuthController.kt new file mode 100644 index 0000000..98cff79 --- /dev/null +++ b/CT-web/src/main/kotlin/com/chuseok22/ctweb/application/controller/auth/AuthController.kt @@ -0,0 +1,51 @@ +package com.chuseok22.ctweb.application.controller.auth + +import com.chuseok22.ctauth.application.dto.request.LoginRequest +import com.chuseok22.ctauth.application.dto.request.ReissueRequest +import com.chuseok22.ctauth.application.dto.response.LoginResponse +import com.chuseok22.ctauth.application.dto.response.ReissueResponse +import com.chuseok22.ctauth.application.service.AuthService +import com.chuseok22.ctauth.infrastructure.user.CustomUserDetails +import com.chuseok22.logging.annotation.LogMonitoring +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/auth") +@Tag(name = "인증 API") +class AuthController( + private val authService: AuthService +) { + + @LogMonitoring + @PostMapping("/login") + fun login( + @Validated @RequestBody request: LoginRequest + ): ResponseEntity { + return ResponseEntity.ok(authService.login(request)) + } + + @LogMonitoring + @PostMapping("/reissue") + fun reissue( + @Validated @RequestBody request: ReissueRequest + ): ResponseEntity { + return ResponseEntity.ok(authService.reissue(request)) + } + + @LogMonitoring + @PostMapping("/logout") + fun logout( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ): ResponseEntity { + authService.logout(customUserDetails.member) + return ResponseEntity.noContent().build() + } +} \ No newline at end of file diff --git a/CT-web/src/main/resources/db/migration/V20260112_132017__create_member_table.sql b/CT-web/src/main/resources/db/migration/V20260112_132017__create_member_table.sql index dde8e22..c7a72a2 100644 --- a/CT-web/src/main/resources/db/migration/V20260112_132017__create_member_table.sql +++ b/CT-web/src/main/resources/db/migration/V20260112_132017__create_member_table.sql @@ -2,7 +2,7 @@ CREATE TABLE member ( id UUID NOT NULL, - student_name varchar(255) NOT NULL, + student_number varchar(255) NOT NULL, name varchar(255) NOT NULL, role varchar(50) NOT NULL, @@ -16,11 +16,11 @@ CREATE TABLE member CONSTRAINT chk_member_role CHECK ( role IN ('ROLE_USER', 'ROLE_ADMIN')) ); --- 활성 회원에 대해서 student_name 유니크 적용 -CREATE UNIQUE INDEX uq_member_student_name_active - ON member (student_name) +-- 활성 회원에 대해서 student_number 유니크 적용 +CREATE UNIQUE INDEX uq_member_student_number_active + ON member (student_number) WHERE deleted = FALSE; -- 조회 인덱스 (학번 + 삭제여부) -CREATE INDEX idx_member_student_name_deleted - ON member (student_name, deleted); \ No newline at end of file +CREATE INDEX idx_member_student_number_deleted + ON member (student_number, deleted); \ No newline at end of file