일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- 도커
- 토이프로젝트
- oauth2
- 오블완
- 액세스토큰
- 스프링
- 트랜잭션
- yaml-resource-bundle
- JIRA
- springsecurity
- 재갱신
- java
- 프로그래머스
- docker
- 국제화
- AWS
- 티스토리챌린지
- 스프링시큐리티
- 백준
- 리프레시토큰
- Spring
- springdataredis
- springsecurityoauth2client
- 스프링부트
- CI/CD
- 파이썬
- 소셜로그인
- 메시지
- githubactions
- 데이터베이스
- Today
- Total
땃쥐네
[토이프로젝트] 게시판 시스템(board-system) 18. 로그인 API 구현 - (2) JWT 기술 적용 본문
이번 글에서는 지난 글에서 설계해둔 로그인 기능에서, 마저 적용하지 못 한 JWT 기술을 적용해보겠습니다.
1. JWT 의존성 추가
enum class Dependencies(
private val groupId: String,
private val artifactId: String,
private val version: String? = null,
private val classifier: String? = null,
) {
// kotlin
KOTLIN_JACKSON(groupId = "com.fasterxml.jackson.module", artifactId = "jackson-module-kotlin"),
KOTLIN_REFLECT(groupId = "org.jetbrains.kotlin", artifactId = "kotlin-reflect"),
KOTLIN_LOGGING(groupId = "io.github.oshai", artifactId = "kotlin-logging", version = "7.0.0"),
// spring
SPRING_BOOT_STARTER(groupId = "org.springframework.boot", artifactId = "spring-boot-starter"),
SPRING_BOOT_WEB(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-web"),
SPRING_BOOT_DATA_JPA(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-data-jpa"),
SPRING_BOOT_MAIL(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-mail"),
SPRING_BOOT_TEST(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-test"),
SPRING_SECURITY_CRYPTO(groupId = "org.springframework.security", artifactId = "spring-security-crypto"),
SPRING_SECURITY_JOSE(groupId = "org.springframework.security", artifactId = "spring-security-oauth2-jose"),
// p6spy
P6SPY_DATASOURCE_DECORATOR(groupId = "com.github.gavlyukovskiy", artifactId = "p6spy-spring-boot-starter", version = "1.9.2"),
// mysql
MYSQL_DRIVER(groupId = "com.mysql", artifactId = "mysql-connector-j"),
// yaml message
YAML_RESOURCE_BUNDLE(groupId = "dev.akkinoc.util", artifactId = "yaml-resource-bundle", version = "2.13.0"),
// email-format-check
COMMONS_VALIDATOR(groupId="commons-validator", artifactId ="commons-validator" , version="1.9.0");
val fullName: String
get() {
if (version == null) {
return "$groupId:$artifactId"
}
if (classifier == null) {
return "$groupId:$artifactId:$version"
}
return "$groupId:$artifactId:$version:$classifier"
}
}
"org.springframework.security:spring-security-oauth2-jose" 를 의존성으로 추가하겠습니다. 이 모듈에는 JWT 생성, 파싱을 위한 기능들이 포함되어 있습니다.
dependencies {
implementation(Dependencies.SPRING_BOOT_STARTER.fullName)
implementation(Dependencies.SPRING_SECURITY_CRYPTO.fullName)
implementation(Dependencies.SPRING_SECURITY_JOSE.fullName)
implementation(project(":board-system-domain:domain-core"))
implementation(project(":board-system-domain:domain-member"))
implementation(project(":board-system-domain:domain-auth"))
testImplementation(testFixtures(project(":board-system-domain:domain-core")))
testImplementation(testFixtures(project(":board-system-domain:domain-member")))
testImplementation(testFixtures(project(":board-system-domain:domain-auth")))
}
External-Security 모듈은 내부적으로 spring-boot-starter, spring-security-crypto, spring-security-oauth2-jose 의존성을 가집니다.
2. JWT 란?
JWT는 JSON Web Token 의 약자로서, 국제 인터넷 표준화 기구(IETF, Internet Engineering Task Force)에 표준화된 기술입니다. RFC-7519에 표준으로 등록되어 있습니다.
이 기술은 JSON 데이터를 서명으로 암호화하여 웹 클라이언트 - 서버 간 데이터를 교환할 수 있도록 만들어졌습니다.
JWT 를 가장 빠르게 확인할 수 있는 곳은 jwt.io 사이트가 아닐까 생각합니다.
이곳에서는 JWT의 예시를 볼 수 있는데요.
xxx.yyy.zzz
JWT는 HEADER(xxx부분), PAYLOAD(yyy부분), SIGNATURE(zzz부분) 세 개의 부분으로 나뉩니다.
복잡한 영문자로 찍혀있고 . 으로 구분되어 있습니다.
HEADER는 이 토큰이 어떤 토큰이고(typ), 어떤 알고리즘으로 서명됐는 지(alg)를 나타냅니다.
PAYLOAD는 토큰이 담은 데이터가 무엇인지 말하는 주장들(JWT Claim Set)을 묶어둔 부분입니다.
그리고 SIGNATURE 는 HEADER(base64).PAYLOAD(base64) 를 alg 에 기술한 알고리즘으로 암호화한 결과물입니다.
HEADER, PAYLOAD 만 놓고보면 누구든지 조작할 수 있기 때문에 신빙성이 없지만
SIGNATURE 부분이 잘 구성되어 있는지 확인할 수 있으면 토큰을 입증할 수 있습니다.
HEADER, PAYLOAD 를 각각 BASE64 디코딩하면 제대로 된 JSON 문자열이 나오고,
SIGNATURE 부분은 디코딩되지 않습니다. SIGNATURE는 앞에서 언급한 것처럼
Base64 인코딩된 HEADER + Base64 인코딩된 PAYLOAD 를 한 번 암호화 한 결과물이기 때문입니다.
사실 엄연히 따지면 JWT는 기술의 표준이며, 추상화된 개념이고 JWS(Json Web Signature), JWE(Json Web Encryption) 두 가지 구현체가 있습니다.
JWS(JSON WEB SIGNITURE) 는 앞서 말한 HEADER.PAYLOAD.SIGNATURE 형태로 헤더/페이로드를 디지털 서명(비대칭키 방식) 또는 MAC(대칭키 방식) 으로 JSON 데이터를 암호화하여 주장(CLAIM)이 옳은지 검증할 수 있도록 만들어진 JWT이고
JWE(JSON WEB Encryption) 은 모든 헤더, 클레임 정보 데이터를 암호화해서, 수신하는 측에서만 확인할 수 있도록 만들어진 JWT 입니다. JWS는 헤더, 페이로드값이 사실상 노출되어 있고 이쪽은 모두 암호화되어 있다는 차이가 있습니다.
보통 많은 분들이 말씀하는 JWT 는 JWS 이며, 제 글에서도 JWS를 JWT로 대체하여 언급하겠습니다.
여기서, 더 나아가 JWT 기술을 사용하기 앞서 JWK 및 암호화에 관한 지식을 간단하게 정리해보겠습니다.
3. 대칭키 / 비대칭키 암호화, 그리고 JWK
JWT 는 크게 두 가지 방식으로 생성할 수 있습니다.
하나는 대칭키 암호화 방식, 또 하나는 비대칭키 암호화 방식입니다.
3.1 대칭키 암호화
대칭키 암호화는 단일 키로 데이터의 암호화와 복호화를 하는 방식으로, 이 방식에서는 하나의 키를 공유하는 양쪽 모두가 데이터 보호와 해독에 사용합니다.
HMAC (Hash-based Message Authentication Code)와 같은 대칭 알고리즘이 이런 대칭키 알고리즘의 대표적인 예입니다.
서명하는 측에서는 원본데이터를 비밀 Key를 사용해 HMAC 알고리즘(이때 사용되는 알고리즘은 SHA-256 등이 있습니다.)을 거쳐서, 암호화된 MAC(1) 을 생성합니다.
검증하는 측에서는 원본데이터와 MAC(1) 을 받고 비밀 Key를 사용해, 원본 메시지를 다시 MAC 알고리즘을 거쳐서 암호화된 MAC(2) 을 생성합니다.
그리고 MAC(1)과 MAC(2)를 비교하여 일치하는 지 확인합니다.
3.2 비대칭키 암호화
비대칭키 암호화는 서로 다른 두 개의 키(공개 키와 개인 키)를 사용합니다. 개인 키는 비밀로 유지되고, 공개 키는 자유롭게 배포될 수 있습니다.
RSA 와 같은 비대칭키 알고리즘이 주로 사용되며, 암호화 과정에서 SHA-256과 같은 해시함수를 함께 사용합니다.
서명하는 측에서는 원본데이터를 개인키로 서명하고, 검증하는 측에서는 공개키를 이용해 검증합니다.
3.3 JWK 구성
{
"p": "6-2o78TncRczlZ4iWjRFR4RfTHwCpxi995RQDlY7W2uM-DsaDCeOs4zB5Sy73YAzRVwmigNYBv6fCNzPDfaCAe9mTqoKf8LhJbmItAWVgu-Cw6y5Qtn5Rhk1X2GOaqxrW62fCW5u4D4gaA2mGLsN0jwbYlVIYlyj3YHX03p10us",
"kty": "RSA",
"q": "qL4dY2-KXJBBrMhGs9QkVqrOCIUt1yWgUIA_EV0IoUIBzFzfkEXWDMIJVz2kr_df_fQp93M8RNUHmMVPmyg2P9oZUaLa1P4Wi6vljTvU1MUGS0E0p1ftXZoiU5uezAbbxXZdGsP3F4RWOLdGFNin1R0rsb6ExD1Y9q6wewfw43c",
"d": "AYOPZO-qAgEOZXV93QcRh5FmBcCzOJUMpcV_pTZ6moDTQjM4vRYJMULZLmcL36rcKpvdom3wCOd6tGou2ETCihgNalo00B9L7hJND9qboKBAII-_cU5I7Hyp50nUONS99TGOxBsY4FzhdPI1MdNrIDof1f7hhG0qNIraBBuxuQm-mF9i3Gp_TuFsSGr8eY6f9B0iitcLFsw7Z0zwAR7bcHhuN7Baox0i-f51ixZXmCo1wlkAJbHT_Muli0sbq2_XE1mIHh-gbpmgZxwVN3rqNW2YopkIyOlNILe7SjT4wornk6JfROl0Agd3X-vcaT1Pf7EUFaQnWuCIAmMb7hQ6SQ",
"e": "AQAB",
"use": "sig",
"kid": "4rxFhhsBJW4FS2KPcrWvv93cdUrk7xBCULi45Pk5CbA",
"qi": "HdnnayTn09LEeXrMF41qpatQCjhe49QsA8Tthsy_r9QHGR3g95MB-4XQ827xsjJfU3nqH6bf2vY6QV-vTblEsXcsaTf3U4DXeAetL0G8L5r6FO5VksZlaxz0pwQ39WLPM6YIXZU8XQyl2jpRZvAPO2PYtIIADAzI3H_8un9Mt80",
"dp": "m_PQfRXii03z6k3KlWgB96FXwV4j_sKLKDHgrWlw-SBh4eBnemtXl_ZOYSgt0uAghBPC4e4N7Sm4hIo5UBiHnbHbJdqe8A8o9t_qrl0WlKOwA5qUJ82gSib1sePx2S-6E7Lz8q8OGarXjxCen7BeFX9n9ps7KtHj-9MNkJxdcBs",
"dq": "bJnnCi8oaoiBbDV9o5E-TfUiI0OrjBNwST8w1_j7a-WyiJehXxZDO_TJ1DcdPg9E3Yn-VH765AYDHKSopmBMAe5ZazwYAQsC8aORWsOOJ94iPd7ah3VElIB15T98BS3I1h5mr6o95gnLADecSCBka_mZPI00n6QoFKO7e4NR2uM",
"n": "m4MsVksDE6_o7xo1-fQvmnQzBRrfnJj4TebjZSp1j2twwzO8tnrlPDRXLDqxpAGJ0Xer2um3T0DYonZ5XCE6H4kIL_-qQ3RidHTRhEgEgfURLbFbGy0IVbPrHZXfXKzbDw2gXdap6jwdn5CCBQ-hKW34Bee6tNaF5r5rHmEJHxcZ7y2Kf59vgtYG81vFOQEYdjwKXIJSCrxBeQJn8wJn8bf4zfjweiSWOoE4OJNiVcqbAsasRms9ob-lOHNPdTGkK6XuzsytHuJten2iLwKxuUWsTH4eVgxffrjOP-PFWhCbVoW7lWWIzkpAOPrBrhSlV-d9fqFpn489r8cHkBtsPQ"
}
JWK 는 데이터를 대칭키(비밀키) 방식으로 암호활 것인지, 비대칭키 방식으로 어떤 키 쌍을 통해 어떤 알고리즘으로 암호활 것인지를 JSON 형태로 구성한 것입니다.
JWK에는 "kty", "kid" 등의 필드들이 있습니다.
이것은 암호화 또는 서명/검증에 사용될 키를 나타내는 메타데이터를 나타냅니다.
어떤 방식으로 암호화되고, 어떤 알고리즘을 사용하며, 어떤 키 쌍을 사용할 것인지에 대한 정보를 제공합니다.
위의 코드블록에서 기술된 JWK는 공개키/비밀키를 모두 함께 포함한 JWK이고
{
"kty": "RSA",
"e": "AQAB",
"use": "sig",
"kid": "rpmUbphWHrKBsXFG9vt3yugRoOBLgxU2FrPV4O7TPus",
"alg": "RS256",
"n": "tyxkM77lLcFhaLrWR63CiggM8vWUOFe07cNyV4FKJ2L7upH95LbaaJ4rrAgdm2m85-S8KYlDaE6S7uQUhddSTaua5Q4nt78HocY8heE9SLUY7CkD6QqJY4sdv49EoLNq17XEb6pRaflgSsKQKCyaRRKt-ERVi6UXrQruPNjd7IAq-jw4KfzE64z5bZCi__t_ky0ZD_2ijGzrFI3FR0eB4zLElsQ5S3MchOzvp79XfRayiGnIjRZs6UNzUC8o79jl0ppnUrsk1x0zy7hMPMqc_NlMVa7e7xHSXwlvKUJy_EI19ImGHUsl_6eV1snO2DAGNwXE84culb0hc15CCwSLiQ"
}
public key만을 포함한 JWK를 따로 관리할 수도 있습니다.
3.4 JWT와 JWK
JWT의 구현체인 JWS 또는 JWE 는 JWK 를 기반으로 하여 생성되며 파싱을 할 때도 JWK 를 사용해서 파싱하도록 기술이 구현되어 있습니다.
즉, JWT를 생성하기 위해서는 JWK가 필요하며 JWK에는 어떤 알고리즘과 어떤 키가 적용될 것인지에 대한 메타데이터가 담긴다 정리할 수 있습니다.
3.5 대칭키 vs 비대칭키
그렇다면 JWT 구성에 있어서 대칭키, 비대칭키 어느 것을 쓸 것인지 결정하는 것이 문제가 될 수 있는데요.
대칭키 방식은 서명하는 측/검증하는 측이 모두 같은 대칭키를 사용하다보니, 서비스가 한 종류일 경우에는 써도 무방합니다. 반면 서비스가 많이 확장되어서 서명하는 측(인증/인가 서버)과 검증하는 측(예:주문서버, 상품서버 ...)이 달라지게 되면 모든 곳에서 보안적으로 노출되어서 안 되는 키를 공유하게 된다는 점에서 위험해질 수 있습니다. 어느 한 서비스라도 보안상 취약이 생겨 대칭키 노출이 되면 위험해질 여지가 있습니다.
비대칭키 방식은 서명/검증 측을 나눴을 때, 서명하는 측에서만 개인키를 관리하고 검증하는 측에서는 외부에 노출되어도 상관 없는 공개키를 가지고 서명하면 되니 보안 상 좀 더 안전합니다.
다만 서명 과정에서 성능 차이는 대칭키 방식이 보통 더 빠른 것으로 알고 있어요.(다만 그렇게 큰 차이는 없을 듯 합니다.)
제 프로젝트에서는 RSA 방식으로 JWT 암호화를 해보겠습니다. 저는 일단 한 대의 서비스를 운영하다보니 대칭키/비대칭키 방식 어느 쪽을 사용하여도 상관은 없습니다만, RSA 비대칭키 쌍을 통해 key 생성을 하는 경험을 해보고 싶었어요.
4. RSA 비대칭키 쌍 생성, 등록
RSA 키쌍 생성 코드를 작성해보겠습니다.
package com.ttasjwi.board.system
import java.io.File
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.util.Base64
const val DIRECTORY = "./board-system-external/external-security/src/main/resources/rsa-keypair"
fun main() {
// 디렉토리 생성
val directory = File(DIRECTORY)
if (!directory.exists()) {
directory.mkdirs()
}
try {
// RSA 키 쌍 생성
val keyPairGenerator = KeyPairGenerator.getInstance("RSA")
keyPairGenerator.initialize(2048) // RSA 2048 비트 키 생성
val keyPair: KeyPair = keyPairGenerator.generateKeyPair()
val publicKey = keyPair.public
val privateKey = keyPair.private
// Base64 인코딩
val base64Encoder = Base64.getEncoder()
val publicKeyEncoded = base64Encoder.encodeToString(publicKey.encoded)
val privateKeyEncoded = base64Encoder.encodeToString(privateKey.encoded)
// PEM 형식에 맞게 64자마다 개행 추가
val publicKeyPem = formatAsPem(publicKeyEncoded, "PUBLIC KEY")
val privateKeyPem = formatAsPem(privateKeyEncoded, "PRIVATE KEY")
// 키를 파일로 저장
File("${DIRECTORY}/public_key_xxx.pem").writeText(publicKeyPem)
File("${DIRECTORY}/private_key_xxx.pem").writeText(privateKeyPem)
println("RSA 키 쌍이 생성되어 파일에 저장되었습니다.")
} catch (e: Exception) {
println("키 생성 및 저장 중 오류가 발생했습니다: ${e.message}")
e.printStackTrace()
}
}
// PEM 형식에 맞게 64자마다 개행
fun formatAsPem(encodedKey: String, keyType: String): String {
val lineLength = 64
val sb = StringBuilder("-----BEGIN $keyType-----\n")
for (i in encodedKey.indices step lineLength) {
val endIndex = (i + lineLength).coerceAtMost(encodedKey.length)
sb.append(encodedKey.substring(i, endIndex)).append("\n")
}
sb.append("-----END $keyType-----")
return sb.toString()
}
RSA 키 생성 애플리케이션입니다. 실행하면
KeyPairGenerator 를 사용해서 RSA 키 쌍을 생성하고 그 결과물인 공개키, 비밀키를 각각 pem 파일로 생성합니다.
실제로 실행하면 이렇게, Public Key, Private Key 가 생성되어지는데요.
이렇게 만들어진 key 들은 로컬, 테스트, 프로덕션 환경 별로 각각 만들어 관리합니다.
로컬, 테스트 용은 보안상 노출되어도 큰 문제가 없으니 그대로 git 에 포함시키도록 했습니다.
대신 프로덕션 용은 반드시 .gitignore 에 등록시켜서 리포지토리에 그대로 올라가지 않도록 합니다!
spring:
config:
activate:
on-profile: local
rsa-keypair:
public-key-path: classpath:/rsa-keypair/public_key_local.pem
private-key-path: classpath:/rsa-keypair/private_key_local.pem
---
spring:
config:
activate:
on-profile: test
rsa-keypair:
public-key-path: classpath:/rsa-keypair/public_key_test.pem
private-key-path: classpath:/rsa-keypair/private_key_test.pem
---
spring:
config:
activate:
on-profile: productionSecret
rsa-keypair:
public-key-path: file:/app/config/board-system/public_key_productionSecret.pem
private-key-path: file:/app/config/board-system/private_key_productionSecret.pem
---
설정에서는 각 프로필 별 public key 경로, private key 경로를 기술해뒀습니다.
package com.ttasjwi.board.system.core.config
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.bind.ConstructorBinding
@ConfigurationProperties(prefix = "rsa-keypair")
class RsaKeyPairProperties
@ConstructorBinding constructor(
val publicKeyPath: String,
val privateKeyPath: String,
)
그리고 위 설정은 RsaKeyPairProperties 라는 클래스에 바인딩 하도록 했습니다.
Main 클래스쪽에 @ConfigurationPropertiesScan 스캔 설정을 해뒀기 때문에 이 설정은 스캔이 되고 설정에 자동적으로 등록될 겁니다.
5. JwtEncoder, JwtDecoder 설정
package com.ttasjwi.board.system.core.config
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.jwk.JWKSet
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jose.jwk.source.ImmutableJWKSet
import com.nimbusds.jose.jwk.source.JWKSource
import com.nimbusds.jose.proc.JWSKeySelector
import com.nimbusds.jose.proc.JWSVerificationKeySelector
import com.nimbusds.jose.proc.SecurityContext
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor
import com.nimbusds.jwt.proc.DefaultJWTProcessor
import com.nimbusds.jwt.proc.JWTClaimsSetVerifier
import com.ttasjwi.board.system.auth.domain.external.spring.security.X509CertificateThumbprintValidator
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.io.ResourceLoader
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
import java.nio.charset.StandardCharsets
import java.security.KeyFactory
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import java.util.*
@Configuration
class NimbusJwtConfig(
private val rsaKeyPairProperties: RsaKeyPairProperties,
private val resourceLoader: ResourceLoader
) {
@Bean
fun jwtEncoder(jwkSource: JWKSource<SecurityContext>): NimbusJwtEncoder {
return NimbusJwtEncoder(jwkSource)
}
@Bean
fun jwtDecoder(jwkSource: JWKSource<SecurityContext>): NimbusJwtDecoder {
val jwsAlgs = mutableSetOf<JWSAlgorithm>()
jwsAlgs.addAll(JWSAlgorithm.Family.RSA)
jwsAlgs.addAll(JWSAlgorithm.Family.EC)
jwsAlgs.addAll(JWSAlgorithm.Family.HMAC_SHA)
val jwtProcessor: ConfigurableJWTProcessor<SecurityContext> = DefaultJWTProcessor()
val jwsKeySelector: JWSKeySelector<SecurityContext> = JWSVerificationKeySelector(jwsAlgs, jwkSource)
jwtProcessor.jwsKeySelector = jwsKeySelector
jwtProcessor.jwtClaimsSetVerifier = JWTClaimsSetVerifier { _: JWTClaimsSet?, _: SecurityContext? -> }
val encoder = NimbusJwtDecoder(jwtProcessor)
// 인코더에서 시간 검증하는 Validator 가 기본 구현체로 등록되어 있는데 이 부분은 우리 코드에서 해야함
// 이 부분을 커스터마이징 함 -> X509CertificateThumbprintValidator 만 사용하도록함
// 근데 이 부분은 해당 모듈 디폴트 클래스로 등록되어 있어서, 그대로 복붙해다가 새로 만들어서 사용함
encoder.setJwtValidator(X509CertificateThumbprintValidator(X509CertificateThumbprintValidator.DEFAULT_X509_CERTIFICATE_SUPPLIER))
return encoder
}
@Bean
fun jwkSource(): JWKSource<SecurityContext> {
val rsaKey = getRsaKey()
val jwkSet = JWKSet(rsaKey)
return ImmutableJWKSet(jwkSet)
}
private fun getRsaKey(): RSAKey {
return RSAKey
.Builder(loadRSAPublicKey())
.privateKey(loadRSAPrivateKey())
.keyID("keyPairId")
.build()
}
/**
* 파일로부터 RSA 공개키를 가져온뒤 RSAPublicKey 객체 생성
*/
private fun loadRSAPublicKey(): RSAPublicKey {
val resource = resourceLoader.getResource(rsaKeyPairProperties.publicKeyPath)
val key = String(resource.inputStream.readAllBytes(), StandardCharsets.UTF_8)
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace("\\s".toRegex(), "")
val decoded = Base64.getDecoder().decode(key)
val spec = X509EncodedKeySpec(decoded)
val keyFactory = KeyFactory.getInstance("RSA")
return keyFactory.generatePublic(spec) as RSAPublicKey
}
/**
* 파일로부터 RSA 개인키를 가져온뒤 RSAPrivateKey 객체 생성
*/
private fun loadRSAPrivateKey(): RSAPrivateKey {
val resource = resourceLoader.getResource(rsaKeyPairProperties.privateKeyPath)
val key = String(resource.inputStream.readAllBytes(), StandardCharsets.UTF_8)
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace("\\s".toRegex(), "")
val decoded = Base64.getDecoder().decode(key)
val spec = PKCS8EncodedKeySpec(decoded)
val keyFactory = KeyFactory.getInstance("RSA")
return keyFactory.generatePrivate(spec) as RSAPrivateKey
}
}
JwtEncoder / JwtDecoder 설정입니다.
1. public key, private key 파일을 읽어오고, 이를 기반으로 key 인스턴스를 생성하여 JWKSource 를 구성합니다.(여기서 JWK 가 구성됩니다.)
2. JWKSource 로 JwtEncoder, JwtDecoder를 구성합니다.
public final class NimbusJwtDecoder implements JwtDecoder {
private final Log logger = LogFactory.getLog(this.getClass());
private static final String DECODING_ERROR_MESSAGE_TEMPLATE = "An error occurred while attempting to decode the Jwt: %s";
private final JWTProcessor<SecurityContext> jwtProcessor;
private Converter<Map<String, Object>, Map<String, Object>> claimSetConverter = MappedJwtClaimSetConverter.withDefaults(Collections.emptyMap());
private OAuth2TokenValidator<Jwt> jwtValidator = JwtValidators.createDefault();
public static OAuth2TokenValidator<Jwt> createDefault() {
return new DelegatingOAuth2TokenValidator(Arrays.asList(new JwtTimestampValidator(), new X509CertificateThumbprintValidator(X509CertificateThumbprintValidator.DEFAULT_X509_CERTIFICATE_SUPPLIER)));
}
그런데 여기서 문제가 있습니다. JwtDecoder 의 구현체인 NimbusJwtDecoder 는 디코딩 과정에서 JwtValidators 들을 통해 Jwt 를 한번 검증하는 과정을 거치는 로직이 있는데요. NimbusJwtDecoder 기본 설정은 현재 시간을 기준으로 유효성 여부를 판단하는 로직이 포함되어 있습니다.(JwtTimeStampValidator)
이 기본 값을 그대로 쓰게 되면 JwtDecoder 가 디코딩 하는 과정에서 현재시간을 기준으로 만료여부를 판단하고 예외를 터트리기 때문에 테스트 통제가 힘들어지고(과거 시점 토큰 생성/파싱을 테스트하려고 하면 무조건 예외가 발생하는 문제), 토큰의 유효시간 만료 여부 판단을 외부기술에 지나치게 의존하게 되는 문제가 생깁니다.
val encoder = NimbusJwtDecoder(jwtProcessor)
// 인코더에서 시간 검증하는 Validator 가 기본 구현체로 등록되어 있는데 이 부분은 우리 코드에서 해야함
// 이 부분을 커스터마이징 함 -> X509CertificateThumbprintValidator 만 사용하도록함
// 근데 이 부분은 해당 모듈 디폴트 클래스로 등록되어 있어서, 그대로 복붙해다가 새로 만들어서 사용함
encoder.setJwtValidator(X509CertificateThumbprintValidator(X509CertificateThumbprintValidator.DEFAULT_X509_CERTIFICATE_SUPPLIER))
return encoder
저는 Jwt 의 유효시간 검증은 애플리케이션/도메인 로직에서 제가 구현할 예정이고 이 부분에 대한 통제를 제가 할 수 있도록 NimbusJwtDecoder에 대해
setJwtValidator 를 통해 Validator 설정을 커스텀하게 변경했습니다.
디폴트 설정에서 JwtTimestampValidator 는 쓰지 않고, X509CertificateThumbprintValidator(X509CertificateThumbprintValidator.DEFAULT_X509_CERTIFICATE_SUPPLIER))
만 사용하도록 변경했어요.
package com.ttasjwi.board.system.auth.domain.external.spring.security
import org.apache.commons.logging.Log
import org.apache.commons.logging.LogFactory
import org.springframework.security.oauth2.core.OAuth2Error
import org.springframework.security.oauth2.core.OAuth2ErrorCodes
import org.springframework.security.oauth2.core.OAuth2TokenValidator
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.util.Assert
import org.springframework.util.CollectionUtils
import org.springframework.web.context.request.RequestAttributes
import org.springframework.web.context.request.RequestContextHolder
import java.security.MessageDigest
import java.security.cert.X509Certificate
import java.util.*
import java.util.function.Supplier
/**
* 스프링 시큐리티의 NimbusJwtDecoder는 토큰 검증 과정에서 시간 검증을 함께 수행합니다.
* 하지만 이 방식을 사용할 경우 테스트 통제가 힘들어집니다.
* 그래서 시간 검증기를 제외하고 검증기 설정을 하기로 했습니다. (시간검증은 우리 코드에서 담당하도록 함)
* 그런데 X509CertificateThumbprintValidator 역시 NimbusJwtDecoder 의 기본 검증기이지만, 생성자를 제공하지 않습니다.
* 그래서 해당 코드를 그대로 복사, 붙여넣기 해서 검증기를 만들었습니다.
*/
internal class X509CertificateThumbprintValidator(x509CertificateSupplier: Supplier<X509Certificate?>) :
OAuth2TokenValidator<Jwt> {
private val logger: Log = LogFactory.getLog(javaClass)
private val x509CertificateSupplier: Supplier<X509Certificate?>
init {
Assert.notNull(x509CertificateSupplier, "x509CertificateSupplier cannot be null")
this.x509CertificateSupplier = x509CertificateSupplier
}
override fun validate(jwt: Jwt): OAuth2TokenValidatorResult {
val confirmationMethodClaim = jwt.getClaim<Map<String?, Any?>>("cnf")
var x509CertificateThumbprintClaim: String? = null
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("x5t#S256")) {
x509CertificateThumbprintClaim = confirmationMethodClaim["x5t#S256"] as String?
}
if (x509CertificateThumbprintClaim == null) {
return OAuth2TokenValidatorResult.success()
}
val x509Certificate = x509CertificateSupplier.get()
if (x509Certificate == null) {
val error = OAuth2Error(
OAuth2ErrorCodes.INVALID_TOKEN,
"Unable to obtain X509Certificate from current request.", null
)
if (logger.isDebugEnabled) {
logger.debug(error.toString())
}
return OAuth2TokenValidatorResult.failure(error)
}
val x509CertificateThumbprint: String
try {
x509CertificateThumbprint = computeSHA256Thumbprint(x509Certificate)
} catch (ex: Exception) {
val error = OAuth2Error(
OAuth2ErrorCodes.INVALID_TOKEN,
"Failed to compute SHA-256 Thumbprint for X509Certificate.", null
)
if (logger.isDebugEnabled) {
logger.debug(error.toString())
}
return OAuth2TokenValidatorResult.failure(error)
}
if (x509CertificateThumbprint != x509CertificateThumbprintClaim) {
val error = OAuth2Error(
OAuth2ErrorCodes.INVALID_TOKEN,
"Invalid SHA-256 Thumbprint for X509Certificate.", null
)
if (logger.isDebugEnabled) {
logger.debug(error.toString())
}
return OAuth2TokenValidatorResult.failure(error)
}
return OAuth2TokenValidatorResult.success()
}
private class DefaultX509CertificateSupplier : Supplier<X509Certificate?> {
override fun get(): X509Certificate? {
val requestAttributes = RequestContextHolder.getRequestAttributes() ?: return null
val clientCertificateChain = requestAttributes
.getAttribute(
"jakarta.servlet.request.X509Certificate",
RequestAttributes.SCOPE_REQUEST
) as Array<X509Certificate>
return if ((clientCertificateChain != null && clientCertificateChain.isNotEmpty())) clientCertificateChain[0]
else null
}
}
companion object {
val DEFAULT_X509_CERTIFICATE_SUPPLIER: Supplier<X509Certificate?> = DefaultX509CertificateSupplier()
fun computeSHA256Thumbprint(x509Certificate: X509Certificate): String {
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(x509Certificate.encoded)
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
}
}
}
그런데 X509CertificateThumbprintValidator 는 디폴트 접근제어자 형태로 접근이 막혀있던 지라... 저는 해당 소스코드를 그대로 복사/붙여넣기 해서 클래스를 하나 만들고, 사용했습니다.
6. Jwt 기반 AccessToken, RefreshToken 생성/파싱 기능 구현
6.1 액세스토큰
package com.ttasjwi.board.system.auth.domain.external
import com.ttasjwi.board.system.auth.domain.exception.InvalidAccessTokenFormatException
import com.ttasjwi.board.system.auth.domain.model.AccessToken
import com.ttasjwi.board.system.auth.domain.model.AuthMember
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm
import org.springframework.security.oauth2.jwt.*
import org.springframework.stereotype.Component
import java.time.ZoneId
import java.time.ZonedDateTime
@Component
class ExternalAccessTokenManagerImpl(
private val jwtEncoder: JwtEncoder,
private val jwtDecoder: JwtDecoder
) : ExternalAccessTokenManager {
companion object {
private const val TOKEN_TYPE_CLAIM = "tokenType"
private const val TOKEN_TYPE_VALUE = "accessToken"
private const val USERNAME_CLAIM = "username"
private const val NICKNAME_CLAIM = "nickname"
private const val EMAIL_CLAIM = "email"
private const val ROLE_CLAIM = "role"
private const val ISSUER_VALUE = "board-system"
private val TIME_ZONE = ZoneId.of("Asia/Seoul")
}
override fun generate(authMember: AuthMember, issuedAt: ZonedDateTime, expiresAt: ZonedDateTime): AccessToken {
val jwt = makeJwt(authMember, issuedAt, expiresAt)
return makeAccessTokenFromJwt(jwt)
}
override fun parse(tokenValue: String): AccessToken {
val jwt: Jwt
try {
jwt = jwtDecoder.decode(tokenValue)
} catch (e: JwtException) {
throw InvalidAccessTokenFormatException(e)
}
val tokenType = jwt.getClaim<String>(TOKEN_TYPE_CLAIM)
if (tokenType != TOKEN_TYPE_VALUE) {
throw InvalidAccessTokenFormatException()
}
return makeAccessTokenFromJwt(jwt)
}
private fun makeJwt(loginMember: AuthMember, issuedAt: ZonedDateTime, expiresAt: ZonedDateTime): Jwt {
val jwsHeader = makeHeader()
val jwtClaimsSet = makeClaimSet(loginMember, issuedAt, expiresAt)
val params = JwtEncoderParameters.from(jwsHeader, jwtClaimsSet)
return jwtEncoder.encode(params)
}
private fun makeHeader(): JwsHeader {
val jwsAlgorithm = SignatureAlgorithm.RS256
val jwsHeaderBuilder = JwsHeader.with(jwsAlgorithm)
return jwsHeaderBuilder.build()
}
private fun makeClaimSet(loginMember: AuthMember, issuedAt: ZonedDateTime, expiresAt: ZonedDateTime): JwtClaimsSet {
return JwtClaimsSet.builder()
.subject(loginMember.memberId.value.toString())
.issuer(ISSUER_VALUE)
.issuedAt(issuedAt.toInstant())
.expiresAt(expiresAt.toInstant())
.claim(TOKEN_TYPE_CLAIM, TOKEN_TYPE_VALUE)
.claim(EMAIL_CLAIM, loginMember.email.value)
.claim(USERNAME_CLAIM, loginMember.username.value)
.claim(NICKNAME_CLAIM, loginMember.nickname.value)
.claim(ROLE_CLAIM, loginMember.role.name)
.build()
}
private fun makeAccessTokenFromJwt(jwt: Jwt): AccessToken {
return AccessToken.restore(
memberId = jwt.subject.toLong(),
email = jwt.getClaim(EMAIL_CLAIM),
username = jwt.getClaim(USERNAME_CLAIM),
nickname = jwt.getClaim(NICKNAME_CLAIM),
roleName = jwt.getClaim(ROLE_CLAIM),
tokenValue = jwt.tokenValue,
issuedAt = jwt.issuedAt!!.atZone(TIME_ZONE),
expiresAt = jwt.expiresAt!!.atZone(TIME_ZONE)
)
}
}
ExternalAccessTokenManagerImpl 입니다.
여기서 액세스토큰 객체 생성 및, 액세스토큰 문자열 파싱을 담당합니다.
액세스토큰 생성(generate) 은 별 문제가 없지만, 액세스토큰 문자열을 파싱하여 액세스토큰 객체 생성을 하는 부분이 좀 까다롭습니다. 예외가 발생할 여지가 많거든요.
class InvalidAccessTokenFormatException(
cause: Throwable? = null,
) : CustomException(
status = ErrorStatus.UNAUTHENTICATED,
code = "Error.InvalidAccessTokenFormat",
args = emptyList(),
source = "accessToken",
debugMessage = "액세스 토큰 포맷이 유효하지 않습니다. 토큰값이 잘못됐거나, 액세스 토큰이 아닙니다.",
cause = cause,
)
파싱에서는 JwtDecoder가 개입하는데 여기서 발생한 예외들은 JwtException 형태로 나오게 됩니다. 우리서비스의 커스텀 예외인 InvalidAccessTokenFormatExcepetion 으로 감싸서 예외를 터트리도록 했습니다.
private const val TOKEN_TYPE_CLAIM = "tokenType"
private const val TOKEN_TYPE_VALUE = "accessToken"
// Jwt 생성 과정
.claim(TOKEN_TYPE_CLAIM, TOKEN_TYPE_VALUE)
또, 앞으로 구현할 RefreshTokenManager 를 통해 생성된 리프레시토큰 값을 JwtDecoder를 통해 파싱하면 정상적으로 Jwt가 만들어질텐데 리프레시토큰과 액세스토큰을 구분하여 token_type 헤더를 따로 작성하고
if (tokenType != TOKEN_TYPE_VALUE) {
throw InvalidAccessTokenFormatException()
}
return makeAccessTokenFromJwt(jwt)
최종적으로 액세스토큰 인스턴스를 생성하는 시점 직전에 토큰의 종류가 액세스토큰이 아니면 예외를 발생시킬 수 있도록 했습니다.
이렇게 하면 액세스토큰 Jwt 문자열 파싱에 대한 예외 처리는 거의 다 가능합니다.
@SpringBootTest
@DisplayName("ExternalAccessTokenManagerImpl 테스트")
class ExternalAccessTokenManagerImplTest
@Autowired constructor(
private val externalAccessTokenManager: ExternalAccessTokenManager,
private val externalRefreshTokenManager: ExternalRefreshTokenManager,
) {
private val authMember = authMemberFixture(
memberId = 1L,
email = "email@test.com",
username = "testusername",
nickname = "testnickname",
role = Role.USER
)
private val issuedAt = timeFixture(minute = 0)
private val expiresAt = timeFixture(minute = 30)
@Nested
@DisplayName("generate: 로그인 한 회원의 인증정보를 담은 액세스 토큰 인스턴스를 생성한다.")
inner class GenerateTest {
@Test
@DisplayName("생성된 토큰 인스턴스는 토큰값을 가진다.")
fun test() {
val accessToken = externalAccessTokenManager.generate(authMember, issuedAt, expiresAt)
assertThat(accessToken.tokenValue).isNotNull()
}
@Test
@DisplayName("생성된 토큰 인스턴스는 전달받은 인증된 회원의 정보를 가진다.")
fun test2() {
val accessToken = externalAccessTokenManager.generate(authMember, issuedAt, expiresAt)
assertThat(accessToken.authMember.memberId).isEqualTo(authMember.memberId)
assertThat(accessToken.authMember.email).isEqualTo(authMember.email)
assertThat(accessToken.authMember.username).isEqualTo(authMember.username)
assertThat(accessToken.authMember.nickname).isEqualTo(authMember.nickname)
assertThat(accessToken.authMember.role).isEqualTo(authMember.role)
}
@Test
@DisplayName("생성된 토큰 객체는 전달받은 발급시간 정보를 가진다")
fun test3() {
val accessToken = externalAccessTokenManager.generate(authMember, issuedAt, expiresAt)
assertThat(accessToken.issuedAt).isEqualTo(issuedAt)
}
@Test
@DisplayName("생성된 토큰 인스턴스는 전달받은 만료시간 정보를 가진다.")
fun test4() {
val accessToken = externalAccessTokenManager.generate(authMember, issuedAt, expiresAt)
assertThat(accessToken.expiresAt).isEqualTo(expiresAt)
}
}
@Nested
@DisplayName("parse: 토큰값을 기반으로 액세스 토큰 객체를 복원한다.")
inner class Parse {
@Test
@DisplayName("복원된 토큰 객체는 처음 생성시점의 액세스 토큰과 동등하다.")
fun test1() {
val accessToken = externalAccessTokenManager.generate(authMember, issuedAt, expiresAt)
val tokenValue = accessToken.tokenValue
val parsedAccessToken = externalAccessTokenManager.parse(tokenValue)
assertThat(parsedAccessToken).isEqualTo(accessToken)
}
@Test
@DisplayName("포맷이 잘못됐을 경우, 예외가 발생한다.")
fun test2() {
val tokenValue = "Adfadfadfadf"
val exception = assertThrows<InvalidAccessTokenFormatException> { externalAccessTokenManager.parse(tokenValue) }
assertThat(exception.cause).isInstanceOf(JwtException::class.java)
}
@Test
@DisplayName("리프레시 토큰을 파싱하려 시도하면 예외가 발생한다.")
fun test3() {
val memberId = memberIdFixture(1L)
val refreshTokenId = refreshTokenIdFixture("abcdef")
val refreshToken = externalRefreshTokenManager.generate(memberId, refreshTokenId, issuedAt, expiresAt)
val tokenValue = refreshToken.tokenValue
assertThrows<InvalidAccessTokenFormatException> { externalAccessTokenManager.parse(tokenValue) }
}
}
}
ExternalAccessTokenManagerImpl 에 대한 테스트는 크게 4개 주제로 테스트했습니다.
1. 생성이 잘 되는지(generate), 생성요청 시 전달한 파라미터가 그대로 액세스토큰 객체에 담기는 지
2. 1을 통해 생성된 정상적인 액세스토큰 문자열로부터, 액세스토큰 인스턴스 복원이 잘 되는지
3. 잘못된 포맷의 액세스토큰 문자열 파싱 과정에서 우리가 정의한 커스텀 예외가 발생하는 지
4. 리프레시토큰 문자열을 파싱하려 했을 때 우리가 정의한 커스텀 예외가 발생하는 지
실제 테스트를 실행해보면 액세스토큰 생성/파싱 기능은 잘 동작합니다.
6.2 리프레시 토큰
package com.ttasjwi.board.system.auth.domain.external
import com.ttasjwi.board.system.auth.domain.exception.InvalidRefreshTokenFormatException
import com.ttasjwi.board.system.auth.domain.model.RefreshToken
import com.ttasjwi.board.system.auth.domain.model.RefreshTokenId
import com.ttasjwi.board.system.member.domain.model.MemberId
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm
import org.springframework.security.oauth2.jwt.*
import org.springframework.stereotype.Component
import java.time.ZoneId
import java.time.ZonedDateTime
@Component
class ExternalRefreshTokenManagerImpl(
private val jwtEncoder: JwtEncoder,
private val jwtDecoder: JwtDecoder
) : ExternalRefreshTokenManager {
companion object {
private const val REFRESH_TOKEN_ID_CLAIM = "refreshTokenId"
private const val TOKEN_TYPE_CLAIM = "tokenType"
private const val TOKEN_TYPE_VALUE = "refreshToken"
private const val ISSUER_VALUE = "board-system"
private val TIME_ZONE = ZoneId.of("Asia/Seoul")
}
override fun generate(
memberId: MemberId,
refreshTokenId: RefreshTokenId,
issuedAt: ZonedDateTime,
expiresAt: ZonedDateTime
): RefreshToken {
val jwt = makeJwt(memberId, refreshTokenId, issuedAt, expiresAt)
return makeRefreshTokenFromJwt(jwt)
}
override fun parse(tokenValue: String): RefreshToken {
val jwt: Jwt
try {
jwt = jwtDecoder.decode(tokenValue)
} catch (e: JwtException) {
throw InvalidRefreshTokenFormatException(e)
}
val tokenType = jwt.getClaim<String>(TOKEN_TYPE_CLAIM)
if (tokenType != TOKEN_TYPE_VALUE) {
throw InvalidRefreshTokenFormatException()
}
return makeRefreshTokenFromJwt(jwt)
}
private fun makeJwt(
memberId: MemberId,
refreshTokenId: RefreshTokenId,
issuedAt: ZonedDateTime,
expiresAt: ZonedDateTime
): Jwt {
val jwsHeader = makeHeader()
val jwtClaimsSet = makeClaimSet(memberId, refreshTokenId, issuedAt, expiresAt)
val params = JwtEncoderParameters.from(jwsHeader, jwtClaimsSet)
return jwtEncoder.encode(params)
}
private fun makeHeader(): JwsHeader {
val jwsAlgorithm = SignatureAlgorithm.RS256
val jwsHeaderBuilder = JwsHeader.with(jwsAlgorithm)
return jwsHeaderBuilder.build()
}
private fun makeClaimSet(
memberId: MemberId,
refreshTokenId: RefreshTokenId,
issuedAt: ZonedDateTime,
expiresAt: ZonedDateTime
): JwtClaimsSet {
return JwtClaimsSet.builder()
.subject(memberId.value.toString())
.issuer(ISSUER_VALUE)
.issuedAt(issuedAt.toInstant())
.expiresAt(expiresAt.toInstant())
.claim(REFRESH_TOKEN_ID_CLAIM, refreshTokenId.value)
.claim(TOKEN_TYPE_CLAIM, TOKEN_TYPE_VALUE)
.build()
}
private fun makeRefreshTokenFromJwt(jwt: Jwt): RefreshToken {
return RefreshToken.restore(
memberId = jwt.subject.toLong(),
refreshTokenId = jwt.getClaim(REFRESH_TOKEN_ID_CLAIM),
tokenValue = jwt.tokenValue,
issuedAt = jwt.issuedAt!!.atZone(TIME_ZONE),
expiresAt = jwt.expiresAt!!.atZone(TIME_ZONE)
)
}
}
ExternalRefreshTokenManagerImpl 구현은 액세스토큰과 거의 똑같습니다.
똑같이 JwtDecoder, JwtEncoder 를 쓰기 때문이죠.
설명은 위에서 했던 것과 거의 같으므로 생략하겠습니다.
7. 실행
실제로 다시 회원가입 절차를 밟아 회원가입을 하고,
로그인을 하면 로그인을 마치고 액세스토큰, 리프레시토큰이 Jwt 형태로 발급됩니다.
jwt.io 를 통해 JWT 문자열을 전달해보면 header, payload 엔 토큰엔 제가 기입한 정보가 잘 담기는 것을 볼 수 있어요.
이 상태로 공개키를 넣어서 검증해보면 Singature Verified 가 보입니다. 올바른 Jwt 토큰임을 알 수 있어요.
이렇게 일단 레디스 저장 기능만 빼면 로그인 기능 구현이 마무리됐습니다.
이어지는 글에서는 레디스 연동을 해볼게요.
읽어주셔서 감사합니다.
리포지토리
https://github.com/ttasjwi/board-system
PR
로그인 API 구현
https://github.com/ttasjwi/board-system/pull/47
'Project' 카테고리의 다른 글
[토이프로젝트] 게시판 시스템(board-system) 20. 스프링 시큐리티와 사용자 인증/인가 (2) | 2024.11.18 |
---|---|
[토이프로젝트] 게시판 시스템(board-system) 19. Redis 연동 (0) | 2024.11.15 |
[토이프로젝트] 게시판 시스템(board-system) 17. 로그인 API 구현 - (1) 설계 (0) | 2024.11.13 |
[토이프로젝트] 게시판 시스템(board-system) 16. 회원가입 API 구현 (0) | 2024.11.13 |
[토이프로젝트] 게시판 시스템(board-system) 15. 이메일 인증 API 구현 (0) | 2024.11.13 |