Nick Dev

[SPURT] Kakao OIDC로 소셜 로그인 구현하기 - 4편 본문

SPURT

[SPURT] Kakao OIDC로 소셜 로그인 구현하기 - 4편

Nick99 2025. 2. 25. 19:58
반응형

목차

  1. 카카오 OIDC 소셜 로그인 구현하기(이론편)
  2. 인증 서버로 요청 보내는 client 선택하기 (Feign VS RestTemplate VS RestClient VS WebClient)
  3. 카카오 OIDC 소셜 로그인 구현하기(구현편)
  4. JWT 기반 인증 구현하기

이제 우리 서비스용 JWT 토큰(access, refresh token)을 발행해보자

 

Jwt 토큰 생성 로직

별 거 없다

그냥 코드 내용 처럼 memberId와 유효기간으로 토큰 생성하면 된다.

만약 memberId 외에 name, email 등을 추가하고 싶으면 Jwts.Builder()에 .addClaims()로 추가하면 된다.

jwt:
  header: Authorization
  secret: weqrwfsdafewjkdshkjghdsajkhvdsjakvhjkladfshijoqwehiuofweqhguiorqehuifdhbvkjldfahvkjadfhfiuwo
  access-token-validity-in-seconds: 3600
  refresh-token-validity-in-seconds: 604800
@Component
class JwtTokenProvider(
    val jwtProperties: JwtProperties,
) {
    // accessToken, refreshToken 생성
    fun generateTokens(memberId: Long): JwtTokenDto {
        val now = LocalDateTime.now()
        val accessToken = generateToken(memberId, now, jwtProperties.accessTokenValidityInSeconds)
        val refreshToken = generateToken(memberId, now, jwtProperties.refreshTokenValidityInSeconds)

        return JwtTokenDto(accessToken, refreshToken)
    }

    // 토큰 검증 (유효 기간 안지난 토큰인지)
    fun validateToken(token: String): Boolean {
        val claims = getClaims(token)
        return !claims.body.expiration.before(Date())
    }

    // 토큰에서 memberId 가져오기
    fun getMemberIdFromToken(token: String): Long {
        val claims = getClaims(token)
        return claims.body.subject.toLong()
    }

    // 토큰 생성
    private fun generateToken(
        memberId: Long,
        now: LocalDateTime,
        validityInSeconds: Long,
    ): String {
        val expiration = now.plusSeconds(validityInSeconds)
        return Jwts.builder()
            .setSubject(memberId.toString())
            .setIssuedAt(Date.from(now.atZone(ZoneId.systemDefault()).toInstant()))
            .setExpiration(Date.from(expiration.atZone(ZoneId.systemDefault()).toInstant()))
            .signWith(jwtProperties.key)
            .compact()
    }

    // token에서 claim 정보 추출하기
    private fun getClaims(token: String): Jws<Claims> =
        try {
            Jwts.parserBuilder()
                .setSigningKey(jwtProperties.key)
                .build()
                .parseClaimsJws(token)
        } catch (e: SignatureException) {
            throw ApplicationException(JWT_INVALID_SIGNATURE, e)
        } catch (e: ExpiredJwtException) {
            throw ApplicationException(JWT_EXPIRED, e)
        } catch (e: MalformedJwtException) {
            throw ApplicationException(JWT_MALFORMED, e)
        } catch (e: UnsupportedJwtException) {
            throw ApplicationException(JWT_UNSUPPORTED, e)
        } catch (e: JwtException) {
            throw ApplicationException(JWT_GENERAL_ERR, e)
        } catch (e: Exception) {
            throw ApplicationException(UNDEFINED_EXCEPTION, e)
        }
}

@ConfigurationProperties(prefix = "jwt")
data class JwtProperties(
    val secret: String,
    val accessTokenValidityInSeconds: Long,
    val refreshTokenValidityInSeconds: Long,
) {
    val key: Key = Keys.hmacShaKeyFor(secret.toByteArray())
}

 

Access token 정상인 경우

 

정상인 경우에는 필터에서 access token 파싱해서 SecurityContextHolder에 Member 객체를 넣어뒀다.

 

Refresh Token 처리 flow

필터를 구현해서 header에서 access token 유무 검사하고, 있으면 통과되고 없으면 에러를 던진다.

만약 access token이 만료된 경우라면 refresh token을 가지고 다시 access token과 refresh token을 재발급 받으면 된다.

이제 그 flow를 사진으로 보자!

 

정리하면 갱신 요청을 refresh token과 함게 보내면

서버에서는 캐시에 저장된 refresh token과 해당 유저의 refresh token이 같은지 비교하고

같으면 새 토큰들 발행하고 refresh token 갱신, 다르면 에러를 던진다.

 

Refresh Token 개발

🖥️ RefreshTokenEntity

@RedisHash 설정으로 하면 따로 RedisConfig 없이 간단하게 Redis에 저장할 수 있다

@Id import문 제대로 확인하시길!!

import org.springframework.data.annotation.Id
import org.springframework.data.redis.core.RedisHash

@RedisHash(value = REFRESH_TOKEN, timeToLive = 60 * 60 * 24 * 7)
data class RefreshTokenEntity(
    @Id
    val id: String,
    val refreshToken: String,
) {
    fun validateRefreshToken(refreshToken: String): Boolean {
        return this.refreshToken == refreshToken
    }
}

🖥️ RefreshTokenRedisRepository

JpaRepository 가 아닌 CrudRepository 다

@Repository
interface RefreshTokenRedisRepository : CrudRepository<RefreshTokenEntity, String>

🖥️ RefreshTokenService

import org.springframework.stereotype.Service

@Service
class RefreshTokenService(
    private val refreshTokenRedisRepository: RefreshTokenRedisRepository,
    private val jwtTokenProvider: JwtTokenProvider,
) {
    /**
     * refresh token을 이용하여 access token과 refresh token을 재발급한다.
     * 재발급한 refresh token은 Redis에 저장한다.
     */
    fun reissueTokens(refreshToken: String): JwtTokenDto {
        // refresh token 검증 & memberId 추출
        val memberId = jwtTokenProvider.getMemberIdFromToken(refreshToken)

        // Redis에서 refresh token 조회
        val refreshTokenEntity =
            refreshTokenRedisRepository.findById(memberId.toString())
                .orElseThrow { ApplicationException(ApplicationExceptionType.JWT_REFRESH_NOT_FOUND_IN_REDIS) }

        // refresh token 검증
        if (!refreshTokenEntity.validateRefreshToken(refreshToken)) {
            throw ApplicationException(ApplicationExceptionType.JWT_REFRESH_INVALID)
        }

        // 토큰 재발급
        val tokens = jwtTokenProvider.generateTokens(memberId)

        // refresh token 저장
        saveRefreshToken(memberId, tokens.refreshToken)

        return tokens
    }

    /**
     * refresh token을 저장한다.
     */
    fun saveRefreshToken(
        memberId: Long,
        refreshToken: String,
    ) {
        refreshTokenRedisRepository.save(RefreshTokenEntity(memberId.toString(), refreshToken))
    }
}

🖥️ AuthController

@RestController
@RequestMapping("/v1/auth")
class AuthController(
    private val authService: AuthService,
    private val refreshTokenService: RefreshTokenService,
) {
    @Operation(summary = "소셜 로그인", description = "소셜 로그인 후 JWT access, refresh 토큰 반환")
    @PostMapping("/login")
    fun socialLogin(
        @RequestBody loginRequest: LoginRequest,
        response: HttpServletResponse,
    ): ResponseEntity<JwtTokenDto> {
        // 소셜 로그인 후, JWT 토큰 반환
        val tokens = authService.authenticateAndRegister(loginRequest)

        return ResponseEntity.ok(tokens)
    }

    @Operation(summary = "JWT 토큰 재발급", description = "refresh 토큰을 이용하여 access, refresh 토큰 재발급")
    @PostMapping("/token/refresh")
    fun refreshAccessToken(
        @RequestBody refreshRequest: RefreshRequest,
    ): JwtTokenDto {
        return refreshTokenService.reissueTokens(refreshRequest.refreshToken)
    }
}

 

끝!

처음 제대로 된 소셜 로그인 구현이라 시간이 오래 걸렸다.

직접 flow chart도 그려보고, 공식 문서도 읽어보고, 블로그 및 깃허브도 보면서 소셜 로그인 과정은 완벽히 이해한거 같다!

직접 flow chart 그려보는게 확실히 구조나 흐름을 이해하기에 굉장히 도움이 되는 거 같다.

 

반응형