[Spring Boot] 인증 및 인가 (Spring Security, JWT)

Hood·2025년 4월 25일
0

Spring Boot

목록 보기
9/14
post-thumbnail

✍ Back-End 지식을 늘리자!

백엔드 개발자를 준비하며 생기는 의문들을 정리한 포스트입니다.


들어가기 전

A&I 동아리원들을 위한 서버가 2달간의 기초CS과정을 거쳐 컷오프가 된 후 제외된 사람들에게
접근이 되지 않도록 로그인 기능을 구현해야 했습니다.
그래서 이 참에 JWT로 Access Token을 생성한 뒤 해당 토큰을 활용하여 사이트 접속에 대한
사용자 인증을 받고 인증된 사용자에 한에 접근을 확인하는 절차인 인가에 대해 파보려고 합니다.


용어 정리

인증 (Authentication)

인증 이란 사용자의 신원을 검증하는 프로세스를 뜻하며 가장 간단한 예시로 ID와 PW를 통해 로그인하는
행위를 인증이라고 하며 쉽게말해 유저가 누구인지 확인하는 절차입니다.

인가 (Authorization)

인가는 인증 이후의 프로세스입니다. 인증된 사용자가 어떠한 자원에 접근할 수 있는지를 확인하는 절차를
인가라고 표현합니다. 즉, 유저에 대한 허용된 권한 안에서 행동하도록 허락하는 것입니다.

접근 주체(Principal)

접근 주체는 보호받는 Rescource에 접근하는 대상을 의미하며
여기서 Recource에 접근하도록 인증을 하는 대상은 비밀번호 입니다.

서블릿 (Servlet)

서버에서 실해오디다가 웹 브라우저에서 요청을 하면 해당 기능을 수행한 후
웹 사이트에 결과를 전송합니다.
클라이언트의 Request에 대해 동적으로 작동하는 웹 어플리케이션 컴포넌트


Spring Security

공식문서 에 따르면
Spring Security는 스프링 기반 어플리케이션의 보안을 담당하는 스프링의 하위 프레임워크입니다.

또한 해당 프레임워크는 CSRF, CORS 등 가장 흔한 보안 취약점도 처리합니다.
따라서 보안과 관련해서 체계적인 많은 옵션을 제공해주기 때문에 개발자의 입장에서는
하나하나 보안 관련 로직을 작성하지 않아도 된다는 장점이 있습니다.
(사용자 이름, 비밀번호 인증, JWT 토큰, OAuth2 등 다양한 보안 표준을 지원)

Java의 HTTP 프로토콜 이해

Java의 모든 웹 애플리케이션은 HTTP 프로토콜을 통해 요청을 받고 응답을 보냅니다.
왜냐하면 브라우저는 HTTP 프로토콜만 이해할 수 있기 때문입니다.
하지만 Java 코드는 이러한 HTTP 프로토콜 요청을 이해할 수 없습니다.
따라서 수신된 HTTP 메시지를 HTTP 요청 객체로 변환해 줄 중간자가 필요합니다
이 중간자를 서블릿 또는 Apache Tomcat과 같은 웹서버라고 부르지만, 복잡한 특성 때문에
아무도 사용하지 않았는데 작업을 쉽게 하기 위해 Springboot가 등장하였고
Springboot는 내부적으로 서블릿을 생성하고 서블릿과 관련된 복잡한 로직을 자체적으로 처리합니다.


출처

여기서 클라이언트 애플리케이션은 Tomcat과 같은 견고한 컨테이너 내부에 배포한 백엔드 웹 애플리케이션에
요청을 보내려고 합니다. 따라서 클라이언트와 실제 서블릿 사이에 필터를 사용합니다.
이 필터는 웹 애플리케이션의 모든 요청을 가로채는 역할을 합니다.
그리고 필터 내부에 정의된 로직을 실행합니다.
서블릿에 대한 내용은 이후 포스트에 한번 다루도록 하겠습니다.
여기서 중요한 점은 모든 요청은 Filter Chain를 거쳐 서블릿이 생성된 후 다시 필터를 거쳐 응답을 반환합니다.

Spring Security 동작 과정

출처

  1. 사용자의 요청 (Request)
    사용자가 /login 등과 같은 API 요청을 보냅니다.
  1. 이 요청은 Spring Securiy Filter Chain 진입하여 필요에 따른 체인을 거칩니다.
  • UsernamePasswordAuthenticationFilter : 로그인 요청을 처리하는 필터
  • BasicAuthenticationFilter : HTTP Basic 인증 처리
  • ExceptionTranslationFilter : 인증 / 인가 실패 예외 처리
  • FilterSecurityInterceptor : 최종 인가 처리
  1. 인증이 필요한 경우 (Ex. 로그인을 하는 경우)
    UsernamePasswordAuthenticationFilter 가 요청을 가로채고, 사용자 ID.PW 등을 추출합니다.

3-1. Authentication 객체 생성
사용자의 ID/PW를 기반으로 UserPasswordAuthenticationToken 생성
3-2. AuthenticationManager에 전달
이 토큰을 AuthenticationManager에 넘기면, 내부적으로 AuthenticationProvider가 이를 처리함.
3-3. UserDetailsService 호출
ID에 해당하는 사용자를 DB에서 조회
3-4. 비밀번호 검사
PasswordEncoder를 이용하여 입력값과 DB의 암호화된 비밀번호를 비교
3-5. 성공 시 SecurityContext에 저장
인증에 성공하면 Authentication 객체가 생성되어 SecurityContextHolder에 저장됨.

  1. 인증이 완료된 후, 접근하려는 URL에 대한 권한을 검사합니다.
    이 과정은 FilterSecurityInterceptor가 담당합니다.
  • PreAuthorize("hasRole('ADMIN')")
  • .antMatchers("/admin/**").hasRole("ADMIN")
    위를 통해 설정된 권한을 기반으로 현재 인증된 사용자의 권한을 확인합니다.
  1. 요청 처리
    인증 + 인가에 모두 성공하면, 요청은 Controller 또는 Handler로 전달되어 실제 로직을 처리하게 됩니다.
  1. 응답 (Response)
    요청 처리 결과를 클라이언트에게 반환합니다.
  1. 로그아웃
    /logout 요청 시 LogoutFilter가 작동하여 SecurityContext를 비우고 세션 등을 무효화시킵니다.

JWT

Jwt는 Json 객체에 인증에 필요한 정보들을 담은 후 비밀키로 서명한 토큰으로 인터넷 표준 인증 방식이다.

JWT 구조


출처

JWT는 Header, Payload, Signature 으로 구성되어 있습니다.

JWT의 헤드에 존재하며 두 가지 정보(typ, alg)를 지니고 있습니다.

  • typ : 토큰 타입을 지정합니다.
  • alg : 해싱 알고리즘을 지정합니다.
    보통은 RS256 (공개/개인키)HS256(비대칭) 가 있습니다.

Payload

사용자 정보의 한 조각인 클레임(Claim)이 들어있습니다.
클래임은 name/value의 한 쌍으로 이루어져있습니다.

  • iss : 토큰 발급자 (issuer)
  • sub : 토큰 제목 (subject)
  • aud : 토큰 대상자 (audience)
  • iat : 토큰이 발급된 시각 (issuedAt)
  • exp : 토큰의 만료 시각 (expired)

Signature

Signature는 헤더와 페이로드의 문자열을 합친 후에 해더에서 선언한 알고리즘과 Key를 이용해 암호한 값이다.
Header와 Payload는 단순히 Base64url로 인코딩되어 있어 누구나 쉽게 복호화할 수 있지만
Segnature는 Key가 없으면 복호화할 수 없다. 이를 통해 보안상 안전하다는 특성을 가질 수 있게 된다.
여기서 Key는 개인키가 될 수 있고 비밀키가 될 수 있습니다.
공개키로 유효성을 검사할 수 있고, 비밀키로 서명하였다면 비밀키를 가지고 있는 사람만이
암호화 복호화, 유효성 검사를 할 수 있습니다.

JWT 프로세스 과정

  1. 사용자가 아이디와 비밀번호 혹은 소셜 로그인을 이용하여 서버에 로그인 요청을 보낼 시
  2. 서버는 비밀 키를 사용해 Json 객체를 암호화한 JWT 토큰을 발급합니다.
  3. 해당 JWT를 헤더에 담아 클라이언트에 보냅니다.
  4. 클라이언트는 JWT를 로컬에 저장해놓습니다.
  5. API 호출을 할 때마다 header에 JWT를 실어 보냅니다.
  6. 서버에서는 헤더를 매번 확인하여 사용자가 신뢰할만한지 체크하고 인증이 되면 API에 대한 응답을 보냅니다.

JWT 기반 인증 시스템에 생성되는 주요 토큰

Access Token (엑세스 토큰)

Access Token 은 클라이언트 서버에 인증된 요청을 보낼 때 사용되는 짧은 수명의 토큰을 의미합니다.
실제 요청마다 Authorization: Bearer <token> 해더에 포함되며
주로 JWT 형식으로 만들어지며 내부에 사용자 정보, 권한, 만료 시간 등이 담깁니다.
사용되는 용도는 서버에 리소스를 요청할 때 사용됩니다.
이는 인증된 사용자임을 증명하는데 사용됩니다.

Reflesh Token (리프레시 토큰)

Reflesh TokenAccess Token이 만료되었을 때 새로운 Access Token을 재발급 받기 위한
긴 수명의 토큰, 일반적으로 서버 또는 DB에 저장하여 관리합니다.
사용되는 용도에는 사용자가 다시 로그인하지 않아도 새로운 Access Token이 발급 가능하며
보안성 Access Token보다 더 중요하며 탈취당하지 않게 방지하는 것이 중요합니다.

토큰이 만료된다면?

Access Token의 JWT 내부에 exp 클레임으로 만료 시각을 확인하는데
만료된 토큰은 인증 실패로 간주되어 서버는 401 Unauthorized를 반환합니다.
Refresh Token을 통해 Access Token을 재발급 받는 방식으로 인증을 유지합니다.

또한 JWT는 기본적으로 상태 비저장(stateless)이기 때문에
발급 후 서버에서 제어할 수 없습니다.
그래서 블랙리스트 저장소인 Redis등을 사용하거나
Refresh Token만 상태 저장을 하고 Access Token은 짧은 생명 주기로 운용하여 토큰을 관리합니다.

JWT 토큰이 어떤 형식인지 확인하고 싶다면?

https://jwt.io/
해당 URL을 접속하여 생성된 token을 입력하면 jwt 토큰이 어떤 구조로 이루어져 있는지 확인가능합니다.


In Kotlin

현재 A&I 과제 서버는 Coroutine + Webflux + MongoDB + Kotlin 으로 이루어져 있습니다.
또한 간단한 인증을 통해 로그인을 하기 때문에 커스터마이징 Security를 설정하였습니다.

의존성 주입

implementation("org.springframework.boot:spring-boot-starter-security")

implementation("io.jsonwebtoken:jjwt-api:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5")

이론에서 설명한 것 처럼 인증 및 인가 구현을 하기 위해서는
Spring security는 토큰에 대한 인증/인가 처리를 할 때 보안을 위한 역할을 하기위해 의존성이 필요합니다.
JJWT는 JWT 생성/파싱/검증을 도와주는 Java 라이브러리를 추가해줍니다.

JWT 토큰 생성 및 추출

//JwtTokenProvider
@Component
class JwtTokenProvider {
    //비밀 키 나중에 보안을 위한 변경 필요
    private val secret = "aandi-super-secret-key-aandi-super-secret-key"
    val secretKey: SecretKey = Keys.hmacShaKeyFor(secret.toByteArray())
    //유효시간 1시간
    private val expiration = 1000 * 60 * 60

    //토큰 생성
    fun createToken(userId: String, roles: MemberRole): String {
        val now = Date()
        return Jwts.builder()
            .subject(userId)
            //권한 목록 추가
            .claim("roles", listOf("ROLE_${roles.name}"))
            .issuedAt(now)
            .expiration(Date(now.time + expiration))
            // HMAC 키로 서명
            .signWith(secretKey)
            .compact()
    }

    //토큰 검증 및 사용자 ID 추출
    fun getUserIdFromToken(token: String): String {
        val claims = Jwts.parser()
            //시크릿 키로 검증
            .verifyWith(secretKey)
            .build()
            //JWT를 파싱하여 payload 추출
            .parseSignedClaims(token)
            .payload

        //토큰에 저장된 사용자 ID 반환
        return claims.subject
    }
}

커스텀 토큰

//JwtAuthenticatedToken.kt
//커스텀 인증 토큰 객체
//JWT에서 파싱한 userId와 권한 목록을 담는 인증 객체 -> 이후 SecurityContext에 저장되어 보호된 리소스에 접근 가능하게 함
class JwtAuthenticatedToken(
    private val userId: String,
    authorities: Collection<GrantedAuthority>
) : AbstractAuthenticationToken(authorities) {
    //
    override fun getCredentials(): Any = ""
    //사용자 주요 정보
    override fun getPrincipal(): Any = userId

    init {
        isAuthenticated = true
    }
}

//JwtAuthenticationToken.kt
//아직 인증되지 않는 상태의 토큰을 담기 위한 객체
class JwtAuthenticationToken(val token: String) : AbstractAuthenticationToken(null) {
    //자격 증명 (JWT 토큰 자체가 자격증명 역할)
    override fun getCredentials(): Any = token
    //사용자 정보를 알 수 없기 때문에 임시로 토큰 자체를 반환함.
    override fun getPrincipal(): Any = token
}

커스터마이징 Security 설정

@Configuration
class SecurityConfig(
    private val jwtTokenProvider: JwtTokenProvider
) {

    @Bean
    fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
        return http
            .csrf { it.disable() } //CSRF 비활성화
            .httpBasic { it.disable() } //HTTPBasic 인증 비활성화
            //경로 기반 인가 정책 설정
            .authorizeExchange {
                // 공개 API
                it.pathMatchers(
                    "/swagger-ui.html",
                    "/swagger-ui/**",
                    "/v3/api-docs/**",
                    "/webjars/**",
                    "/api/member/login"
                ).permitAll()

                // 관리자만 가능
                it.pathMatchers(HttpMethod.POST, "/api/report").hasRole("ADMIN")
                it.pathMatchers(HttpMethod.PUT, "/api/report/{id}").hasRole("ADMIN")
                it.pathMatchers(HttpMethod.DELETE, "/api/report/{id}").hasRole("ADMIN")

                it.pathMatchers(HttpMethod.POST, "/api/member/register/member").hasRole("ADMIN")
                it.pathMatchers(HttpMethod.GET, "/api/member").hasRole("ADMIN")
                it.pathMatchers(HttpMethod.DELETE, "/api/member/{userId}").hasRole("ADMIN")
                // 나머지 report는 인증만 필요
                it.pathMatchers("/api/report/**").authenticated()

                // 그 외 모두 허용
                it.anyExchange().permitAll()
            }
            //JWT 인증을 처리하는 커스텀 필터 추가
            .addFilterAt(bearerAuthFilter(), SecurityWebFiltersOrder.AUTHENTICATION)
            .build()
    }

    //JWT 기반 인증 필터 설정
    private fun bearerAuthFilter(): AuthenticationWebFilter {
        //인증 로직을 담당하는 인증 매니저 설정
        val filter = AuthenticationWebFilter(jwtAuthenticationManager())
        //HTTP 요청에서 JWT를 추출하는 converter
        filter.setServerAuthenticationConverter(bearerConverter())
        //JWT는 stateless이므로 상태를 저장하지 않음
        filter.setSecurityContextRepository(NoOpServerSecurityContextRepository.getInstance())
        //특정 경로에서만 이 필터가 작동 (인증이 필요한 URL)
        filter.setRequiresAuthenticationMatcher(ServerWebExchangeMatchers.pathMatchers("/api/report/**",
            "/api/member/register/member",
            "/api/member",
            "/api/member/{userId}"))
        return filter
    }

    //요청에서 Authorization: Bearer <Token> 헤더를 추출해서 해당 객채(JwtAuthenticationToken)로 변환
    private fun bearerConverter(): ServerAuthenticationConverter {
        return ServerAuthenticationConverter { exchange ->
            val authHeader = exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION)
            if (authHeader != null && authHeader.startsWith("Bearer ")) {
                val token = authHeader.substring(7)
                Mono.just(JwtAuthenticationToken(token))
            } else {
                Mono.empty()
            }
        }
    }

    //실제 JWT 인증을 수행하는 인증 매니저 (커스터마이징)
    private fun jwtAuthenticationManager(): ReactiveAuthenticationManager {
        return ReactiveAuthenticationManager { auth ->
            //JWTToken 추출
            val jwtToken = (auth as JwtAuthenticationToken).token

            //JWT 파싱
            val claims = Jwts.parser()
                .verifyWith(jwtTokenProvider.secretKey)
                .build()
                .parseSignedClaims(jwtToken)
                .payload

            //userId 추출
            val userId = claims.subject
            //권한 추출
            val roles = claims["roles"] as? List<*> ?: emptyList<Any>()

            //권한을 GrantedAuthority 변환
            val authorities = roles.mapNotNull {
                (it as? String)?.let { role -> SimpleGrantedAuthority(role) }
            }

            //인증 완료 객체 반환
            Mono.just(JwtAuthenticatedToken(userId, authorities))
        }
    }

    @Bean
    //페스워드 Encoder
    fun passwordEncoder(): PasswordEncoder {
        return object : PasswordEncoder {
            override fun encode(rawPassword: CharSequence): String = rawPassword.toString()
            override fun matches(rawPassword: CharSequence, encodedPassword: String): Boolean =
                rawPassword.toString() == encodedPassword
        }
    }
}

Service

@Service
class MemberService (
    private val memberRepository: MemberRepository,
    private val jwtTokenProvider: JwtTokenProvider,
    private val passwordEncoder: PasswordEncoder
){
    //랜덤 패스워드 메소드
    private fun randomPassword(length: Int = 10): String {
        val char = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
        return (1..length).map {char.random()}.joinToString("")
    }

    //A&I 사용자 계정 자동 생성 메소드 (권한은 Admin)
    fun registerMember(request: RegisterMemberRequest): Mono<MemberResponse> {
        return memberRepository.findByRoleOrderByUserIdDesc(MemberRole.MEMBER)
            .next() // 가장 최근 등록된 MEMBER 1명만 가져옴
            .defaultIfEmpty(Member(userId = "aandiUser00", nickName = "", password = "", role = MemberRole.MEMBER))
            .map { lastMember ->
                val num = lastMember.userId.removePrefix("aandiUser").toInt() + 1
                "aandiUser%02d".format(num)
            }
            .flatMap { newUserId ->
                val rawPassword = randomPassword()
                val encodedPassword = passwordEncoder.encode(rawPassword)
                val member = Member(
                    userId = newUserId,
                    nickName = request.nickname,
                    password = encodedPassword,
                    role = MemberRole.MEMBER
                )
                memberRepository.save(member).map {
                    MemberResponse(
                        userId = it.userId,
                        nickname = it.nickName,
                        role = it.role,
                        rawPassword = rawPassword
                    )
                }
            }
    }

    //로그인 메소드
    fun login(request: LoginRequest): Mono<TokenResponse> {
        return memberRepository.findByUserId(request.userId)
            .switchIfEmpty(Mono.error(IllegalArgumentException("유저를 찾을 수 없습니다")))
            .flatMap { user ->
                if (!passwordEncoder.matches(request.password, user.password)) {
                    Mono.error(IllegalArgumentException("비밀번호가 일치하지 않습니다"))
                } else {
                    val token = jwtTokenProvider.createToken(user.userId, user.role)
                    Mono.just(TokenResponse(token))
                }
            }
    }

    //모든 멤버를 불러오는 메소드
    fun getAllMembers(): Flux<MemberResponse> {
        return memberRepository.findAll()
            .map { member ->
                MemberResponse(
                    userId = member.userId,
                    nickname = member.nickName,
                    role = member.role
                )
            }
    }

    //멤버를 삭제하는 메서드
    fun deleteMemberByUserId(userId: String): Mono<String> {
        return memberRepository.findByUserId(userId)
            .switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND, "아이디가 존재하지 않습니다: $userId")))
            .flatMap { member ->
                memberRepository.delete(member).thenReturn("아이디 삭제 완료: $userId")
            }
    }
}

Controller

@Tag(name = "MEMBER API", description = "A&I 멤버 관리 API입니다.")
@RestController
@RequestMapping("/api/member")
class UserController(
    private val memberService: MemberService
) {
    @Operation(summary = "멤버 생성 API", description = "A&I에 접근하는 멤버를 생성하는 API입니다.")
    @PostMapping("/register/member")
    fun registerMember(@RequestBody request: RegisterMemberRequest): Mono<MemberResponse> {
        return memberService.registerMember(request)
    }

    @Operation(summary = "로그인 API", description = "A&I에 접근하는 멤버의 아이디와 패스워드가 맞다면 토큰을 내보내는 API입니다.")
    @PostMapping("/login")
    fun login(@RequestBody request: LoginRequest): Mono<TokenResponse> {
        return memberService.login(request)
    }

    @Operation(summary = "모든 멤버 조회 API", description = "A&I 모든 멤버를 조회하는 API입니다.")
    @GetMapping
    fun getAllMembers(): Flux<MemberResponse> {
        return memberService.getAllMembers()
    }

    @Operation(summary = "멤버 삭제 API", description = "A&I 멤버를 삭제하는 API입니다.")
    @DeleteMapping("/{userId}")
    fun deleteMember(@PathVariable userId: String): Mono<String> {
        return memberService.deleteMemberByUserId(userId)
    }
}

Swagger

테스트를 위한 JWT 인증 방식을 설정하였습니다.

@Configuration
class SwaggerConfig {
    @Bean
    fun openApi(): OpenAPI {
        return OpenAPI()
            .components(
                //JWT 토큰 보안 인증 설정
                Components().addSecuritySchemes(
                    "bearer-key",
                    SecurityScheme()
                        .type(SecurityScheme.Type.HTTP)
                        .scheme("bearer")
                        .bearerFormat("JWT")
                )
            )
            .addSecurityItem(SecurityRequirement().addList("bearer-key"))
    }

    private fun swaggerInfo() : Info = Info()
        .title("A&I 3기 과제 공지용 웹서버")
        .description("A&I 3기 과제 공지를 하기 위한 웹서버 Api 명세서입니다.")
        .version("1.0.0")
}

A&I 과제 서버의 인증/인가 동작 과정 설명

인증 - 1. 로그인 요청

사용자가 /api/member/login을 통해 사용자 아이디와 비밀번호를 사용하여 인증을 시도합니다.
정상적으로 인증되면 서버는 JWT 토큰을 발급하여 클라이언트에 반환합니다.
이 토큰을 클라이언트가 이후의 요청에서 자격증명으로 사용됩니다.

2. JWT 토큰 발급

fun createToken(userId: String, roles: MemberRole): String {
    val now = Date()
    return Jwts.builder()
        .subject(userId)
        .claim("roles", listOf("ROLE_${roles.name}"))
        .issuedAt(now)
        .expiration(Date(now.time + expiration))
        .signWith(secretKey)
        .compact()
}

로그인 성공시 JWT 토큰이 생성됩니다.
여기서는 userId, roles와 합께 발급되며 비밀 키를 통해 서명됩니다.
토큰에는 사용자의 아이디, 역할 정보가 포함되며 이후 요청에서 사용자와 권한을 식별할 수 있게 합니다.

인가 - 3. JWT 토큰을 통한 인증 정보 확인

사용자가 요청을 보낼 때, JWT 토큰을 Authorization 헤더에 포함시켜 보냅니다.
서버는 이 JWT 토큰을 bearerAuthFilter() 필터에서 추출하여 JwtAuthentiationToken 객체로 반환합니다.

private fun bearerConverter(): ServerAuthenticationConverter {
    return ServerAuthenticationConverter { exchange ->
        val authHeader = exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION)
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            val token = authHeader.substring(7)
            Mono.just(JwtAuthenticationToken(token))
        } else {
            Mono.empty()
        }
    }
}

4. JWT 인증 정보 검증

ReactiveAuthenticationManager 가 JWT 토큰을 파싱하고 서명을 검증하여 유효성을 확인합니다.

val claims = Jwts.parser()
    .verifyWith(jwtTokenProvider.secretKey)
    .build()
    .parseSignedClaims(jwtToken)
    .payload

JwtTokenProvider를 사용하여 토큰을 서명을 검증하고 climes 객체에서 userId 와 roles를 추출합니다.

5. 권한 부여 (GrantedAuthority)

val authorities = roles.mapNotNull {
    (it as? String)?.let { role -> SimpleGrantedAuthority(role) }
}

토큰에서 추출한 roles 정보를 GrantedAuthority 객체로 변환하여 인증된 사용자의 권한을
Spring Security 가 사용할 수 있도록 제공합니다.

6. 인증된 토큰 생성

JwtAuthenticatedToken 객체에 사용자 아이디와 권한을 담아 반환합니다.

Mono.just(JwtAuthenticatedToken(userId, authorities))

이 객체는 인증된 사용자로 처리되며 이후 요청에는 인증된 사용자 정보와 권한을 바탕으로 인가 처리가
이루어집니다. 위는 기본 사용자는 Member 권한이기 때문에 생성된 모든 멤버를 볼 수 없습니다.

7. SecurityContext에 사용자 정보 저장

인증이 완료되면 인증된 사용자 정보가 SecurityContext에 저장됩니다.
Spring Security는 JwtAuthenticatedToken을 Principal로 등록하여
이후 모든 보안 처리가 가능하도록 만듭니다.

Swagger 인증/인가 테스트

8. Swagger UI에서 JWT 토큰을 사용한 인증 테스트

Swagger UI에서 설정한 Bearer Token을 사용한 인증을 설정합니다.
Swagger UI에서 API를 호출할 때 JWT 토큰을 입력하여 인증할 수 있습니다.

@Bean
fun openApi(): OpenAPI {
    return OpenAPI()
        .components(
            Components().addSecuritySchemes(
                "bearer-key",
                SecurityScheme()
                    .type(SecurityScheme.Type.HTTP)
                    .scheme("bearer")
                    .bearerFormat("JWT")
            )
        )
        .addSecurityItem(SecurityRequirement().addList("bearer-key"))
}

Swagger UI에서 Authorization 해더에 Bearer <JWT Token>을 입력하고
API요청을 테스트할 수 있습니다.


📌 결론

로그인에 대해 어떠한 과정으로 인증 / 인가가 이루어지는지에 대해 모르고 구현에 중점을 둔 것 같은데
새로운 지식을 알 수 있어서 좋았습니다. 해당 포스트를 하면서 서블릿, Spring Security의 동작과정 등
확장되는 지식이 점점 더 생기는 것 같습니다. 백엔드 개발자를 목표로 한다면 무조건 해봐야하는
로그인에 대한 개념을 잘 기억합시다.

[참고자료]

profile
달을 향해 쏴라, 빗나가도 별이 될 테니 👊

0개의 댓글