JWT 인증 (1) Access Token 발행(Spring Boot 3 + Java 17)

Letsdev·2023년 4월 23일
8
post-thumbnail
post-custom-banner

Link

>  깃허브에서 보기

링크를 우클릭해서 "링크 주소 복사"를 누르신 후, 복사된 문자열의 루트 도메인이 "github.com"인지 확인한 후 안전하게 이동하시면 됩니다.

This Project Uses

  • Syntax Level: Java 17(JDK 17)
  • 스프링 부트 3.0
  • JPA, JJWT, 스프링 시큐리티

Features

JWT 인증에 집중합니다.

  • 회원가입은 일부러 포함하지 않았습니다.(필요한 데이터는 DB 구축 시 생성되도록 했습니다. 소스 코드가 여러분을 헷갈리게 하지 않도록 SQL로 분리를 했습니다.)
  • 인증은 Authentication Manager를 통해 수행했습니다. 예외가 안 뜨고 넘어가면 인증된 거고, 인증 정보가 안 맞으면 403 Forbidden으로 맞이해 줍니다.
  • 인증 후에는 JWT Acccess Token을 발행해 주면 됩니다.
  • 신택스 수준: JDK 17

Focus on JWT Authentication.

  • No Sign Up(Initial data is inserted when we construct database. So source code will not confuse you.)
  • Authentication: using Authentication Manager(General usage).
  • JWT Authentication Token Provider: Generate JWT Access Token.
  • Syntax Level: JDK 17(Java 17)

Background

JWT 토큰을 이해하는 분들은 이곳으로 건너뛰셔도 됩니다.

JWT 인증이라는 것은

액세스 토큰을 관리하는 것을 말합니다.

Figure: 고전적으로 세션을 통해 이루어진 로그인 관리 고전적인 로그인 관리는 서버가 세션에 유저를 관리함으로써 이루어졌다는 이미지 Figure: 여러 서버가 있을 때 세션을 통한 인증 방식의 불편함 서버가 분산됨에 따라 유저의 세션 아이디로 그 유저의 인증 정보를 식별할 수 있는 서버가 별로 없다는 데서 문제가 됨을 나타낸 이미지 Figure: 서명(시그니처)에 사용할 시크릿키를 공유받는 서버들 시크릿키를 공유받는 서버들 Figure: 한 서버가 시크릿키로 서명한 토큰을 클라이언트에 준다. 시크릿키로 서명한 토큰을 사용자에게 주는 이미지 Figure: 이 토큰이 JWT 인증 방식의 액세스 토큰이며, 위변조 시 발각된다. 위변조하면 들키는 토큰이라는 문구와 토큰 그림을 담은 이미지 Figure: 인증 서버 외에도 다른 서버들 또한 시크릿키를 공유받았으므로, 위변조된 토큰은 바로 알아볼 수 있다. 위변조 시 다른 서버들도 바로 알 수 있다는 것을 담은 그림 Figure: 따라서 올바르게 유지된 토큰은 서버들이 타당하다고 수용한다. 올바른 토큰을 받고 따봉을 날리는 서버 이미지 Figure: 복잡하게 DB 안 거쳐도 타당한 토큰을 알아볼 수 있어 기뻐하는 서버들. 시크릿키를 공유받는 서버들

JWT 인증 관리는 액세스 토큰(Access Token)을 관리하는 겁니다. 이것은 로그인의 한 방식이고, 서버가 분산되면 굉장히 유용한 방식입니다. 원래라면 보안과 거리가 있어야 할 클라이언트 사이드에서, 인증 서버나 API 서버와 문자열로 된 '토큰'이라는 것을 주고 받음으로써 보안에 동참하게 됩니다. 핵심은 이 토큰이 위•변조 될 수 없다는 것입니다.

고전적으로 세션을 통해 관리해 왔던 로그인은, 서버가 분리됨에 따라 차츰 JWT 인증 방식으로 대세가 전환되었습니다. 세션은 서버마다 소유한다는 게 문제가 되었습니다. 서버가 다양하게 분리될 때 어느 사용자가 어느 서버에 로그인한다면 그 서버의 세션에만 로그인 정보가 담길 테니, 여러 서버가 그 사용자의 로그인 여부를 알기 위해서는 세션에만 담는 기존 방식에서 벗어나야 했습니다.

서버가 분리되면 이처럼 세션/캐시/데이터베이스 공유나 매칭을 위한 작업들도 번거로워지거나 실수하기 좋아지거나 리스크를 띠는 작업이 될 여지가 있었습니다.

그중 인증 여부에 관한 정보는, 서버 간에 공유되는 휘발성(또는 비휘발성이어도 되는데) 수단을 통해 공유하여도 되고, 한 사용자가 일단 브라우저를 통해 사이트에 접속을 하고 나면 그(때 발생한) 세션 아이디를 하나의 서버로만 연결해 준다든지(스티키 세션) 하는 방식들도 있습니다. 하지만 디스크를 읽고 쓰는 작업에서 벗어날 수 있도록 해 준 것이 JWT 인증 방식입니다.

JWT에서 사용되는 '해싱'이라는 것은 어떤 데이터의 무결성을 검토하는 데에 유용하면서도, 또한 우리가 비밀만 잘 지킨다면 위변조가 불가능한 방식으로 인정받는 방식입니다. 해싱은 값을 갈아 버리는 행위로, 이것은 복호화가 안 될 만큼 보안 강도가 높습니다. 대신, 같은 값을 갈면 같은 결과가 나오도록 보장하기 때문에 값이 올바른지 검토하는 데에는 아주 훌륭한 방식입니다. JWT는 목적 데이터를 주고 받을 때, 이런 해싱을 통해 만든 '서명(시그니처)'을 함께 주고 받음으로써 이 데이터가 알맞은 정보이고, 결코 위변조된 데이터가 아니라는 것을 보장할 수 있습니다. 이렇기 때문에, 서버들은 이 서명의 유효성만 검토할 수 있으면 그 데이터를 신뢰할 수 있게 되는 것입니다.

이때 중요한 것은, 이 해싱을 하는 데에 필요한 '시크릿'. 비밀을 지켜서 남들은 우리와 똑같은 해싱을 수행하지 못하도록 방지하는 것입니다. 그래서 우리만 이것을 똑같이 할 수 있도록 한다고 해서 '서명'이라고 부르는 것으로 생각합니다. 그렇다면 JWT로 인가 토큰을 구현할 때는 이 '시크릿'을 서버들이 알고 있도록 설정을 넣어 줄 것이고, 해싱을 할 때는 그 시크릿을 사용해서 사용자에게 데이터와 함께 전달하는 과정을 구현해 가는 것이 되겠지요.

이렇게 하면 클라이언트를 거쳐서 온 데이터들도 신뢰 가능한 대상이 되기 때문에, 굳이 서버 뒷단에서 데이터베이스 등 공유된 디스크를 읽고 쓰는 작업 없이도, 사용자의 각종 요청에 대해 권한이 있는지/로그인은 되어 있는지 여러 서버가 확인을 할 수 있고, 주고 받은 목적 데이터도 활용할 수 있는 것입니다.

이처럼 서버들이 분산되어 있어도 클라이언트로부터 올바른 정보를 받았는지 알 수 있는 인증 방식입니다. 다만, 이것이 신뢰도를 가지려면 시크릿 키를 주기적으로 재발급하고, 중간에 토큰이 탈취되지 않도록 HTTPS 프로토콜을 꼭 사용하고, 액세스 토큰의 수명을 짧게 유지하는 등 노력이 필요합니다.

리프레시 토큰은 사실 별개입니다.

JWT를 검색하다 보면 리프레시 토큰도 많이 보았을 겁니다.

앞서 주의사항처럼 덧붙인 말로, 액세스 토큰의 수명이 짧게 유지된다는 말은, 액세스 토큰만으로는 로그인이 금방 해제되는 것과 같은 효과를 가진다는 말입니다. 그래서 사용하는 것이 리프레시 토큰이고, 이 리프레시 토큰은 가급적 클라이언트에서 악성 스크립트가 접근하지 못하도록 HTTP Only 쿠키에 담아서 관리하는 편입니다. HTTP Only 쿠키는 브라우저와 서버 사이에만 주고 받는 쿠키고, 클라이언트단 자바스크립트 코드가 접근할 수 없는 쿠키 유형이니, 악성 스크립트가 이 리프레시 토큰을 탈취하려는 시도도 방지될 것입니다. 인증 서버만 이 리프레시 토큰을 받을 수 있으면 됩니다.

리프레시 토큰도 액세스 토큰처럼 만료 시간이 있기는 하지만, 액세스 토큰보다는 길게 정하는 편입니다. 우리가 흔히 사용하는 서비스에서 우리가 접속하지 않아도 로그인이 유지되고 있는 시간이라고 생각하면 대부분 일치하거나 비슷한 개념입니다. 리프레시 토큰의 만료 시간은 그렇고, 액세스 토큰은 사용자 모르게 꾸준히 재발급이 되도록 프론트엔드 개발자와 백엔드 개발자가 호흡을 맞춰 놓은 상태였을 겁니다. 그래서 실제로는 액세스 토큰이 재발급되었지만 사용자는 인지하지 못하고, 리프레시 토큰이 만료되는 경우에는 로그아웃 처리가 됩니다.

참고로 액세스 토큰까지만 구현해도 사실 JWT 인증 방식까지는 구현이 된 것입니다. 따라서 이번 포스팅에서는 액세스 토큰 발급을 구현하는 데에 초점을 두었고, 리프레시 토큰은 그 다음 단계에 담아 두었습니다(예정). 사실 리프레시 토큰이라는 것은 JWT로 관리하는 것도 아니고, 그저 데이터베이스 등에 잘 담아서 관리하는 뻔한 방식이라서 어렵지는 않습니다. 아마 액세스 토큰 구현을 마치고 나면, 스스로 도전해 볼 수도 있을 것입니다.

리프레시 토큰이라고 하는 것은, 인증 서버가 액세스 토큰과 함께 생성해서 클라이언트에게도 주고, 데이터베이스 등에도 보존합니다. 클라이언트와 서버가 나눠 가지는 것입니다. 리프레시 토큰을 생성하는 방식은 다들 다르지만, 임의의 값을 부여하는 방식이고, 예측하기 어려운 임의의 값을 발급할수록 좋습니다.

  • 클라이언트에서 액세스 토큰이 만료되고 나면, 클라이언트에서는 리프레시 토큰을 가지고 인증 서버에 리프레시(액세스 토큰 재발급)를 요청하게 됩니다.
  • 서버는 데이터베이스 등에서 이 리프레시 토큰이 유효한지 확인하고, 유효하다면 액세스 토큰을 새로 만들어 주면 됩니다. 이게 리프레시 토큰 구현의 거의 다입니다.
  • 참고할 것은, 사용자마다 여러 기기에서 로그인을 유지할 수 있기 때문에 리프레시 토큰은 사용자당 하나가 아니라 접속 환경(기기/브라우저)마다 하나씩이라고 이해하시면 됩니다.

그리고 다음 내용들은 부가적으로 알고 적용해 보면 좋습니다.

액세스 토큰을 재발급할 때 리프레시 토큰도 재발급하여 리프레시 토큰을 일회용으로 만듭니다.

리프레시 토큰을 매번 새로 발급해 주고 기존 내용을 만료시키는 겁니다. 리프레시 토큰이 일회용이 되기 때문에 사용자 부주의 등으로 인한 탈취 가능성도 더욱 감소합니다. 또 매번 재발급을 하면서 리프레시 토큰의 수명이 매번 연장되어서 서비스 제공에도 편리합니다.

리프레시토큰은 정상적이라면 접속 환경(디바이스/브라우저)에 종속적입니다.

리프레시 토큰을 저장할 때 전략은 구현하는 사람마다 다를 것입니다. 클라이언트와 협의해서 추가 정보를 받아도 되고, 리프레시 토큰을 발급할 때 요청 헤더 등으로부터 접속 환경에 관한 정보를 추출하여 같이 보존할 수 있습니다.

  • 리프레시를 할 때 접속한 기기나 브라우저가 달라졌다고 인식되면 비정상적인 것으로 취급합니다.
  • 일부 서비스는 이 리프레시 토큰이 마지막으로 사용된 지역 정보를 제공해 주기도 합니다.

구현에서

이번 프로젝트에는 액세스 토큰의 발급만 담았습니다. 액세스 토큰이 와닿으면 리프레시 토큰 구현은 뻔해서 비교적 쉬우니, 신경 분산 안 시키고 액세스 토큰에 집중하려는 분들은 이 포스팅을 보시고, 리프레시 토큰까지 무조건 같이 봐야겠다 하면 다음 포스팅(예정)을 보세요.

버전이 오르면서 달라진 내용들을 반영하여 최신화했습니다.

  • 이번: JWT 인증에 가장 베이스가 되는 코드를 담은 프로젝트
  • 다음: 리프레시 토큰을 관리하는 프로젝트
  • 다다음인데 생략 킹능성: 소셜 로그인이 가능하도록 개선하는 코드

모든 것은 Spring Boot 3.0에 맞춰져 있고, 일부 문법이 JDK 17 스펙을 따릅니다.


역할 설명

Overview Table

기존에 Deprecated 된 것들은 새로운 것으로 대체하여 작성해 두었습니다.

클래스역할커스텀 여부
AuthenticationManager인증 수행기본 제공 객체
(UserDetailsService를 구현한 클래스)DB 등에서 데이터를 조회하여 AuthenticationManager에게 제공하는 역할입니다. AuthenticationManager는 인증할 때 이 클래스의 객체가 매핑해 준 정보를 쓰게 됩니다.커스텀 필수
JwtProvider(인증 후에 따로 실행하여) JWT 인증 토큰을 발행커스텀 필수
JwtParserJWT를 해석기본 제공 객체

부가적인 것들

클래스/파일역할비고
BaseEntityJPA 엔티티의 공통 상위 클래스
MemberEntity회원 정보를 담은 JPA Entity
PasswordEncoderFactory데이터 이관 등에 의하여 Authentication Manager가 지원하지 않는 형식으로 인코딩 되어 있던 비밀번호는 별도 인코더를 생성하여 대조할 수 있습니다. 이번 예시에서는 그런 인코더는 포함하지 않았습니다.
~ProjectionDB에서 조회할 때 일부 컬럼만 조회하기 위한 DTO 역할입니다.
예외 관리
AuthenticationErrorCode인증 과정에서 발생할 수 있는 예외를 취급하는 에러 코드입니다.implements ErrorCode
AuthenticationException인증 과정에서 발생할 수 있는 커스텀 예외입니다.
ErrorCode여러 에러코드의 공통 기능을 담은 인터페이스입니다.
상수 관리
~Constants컴파일타임에 사용 가능한 상수를 작성합니다. 코드 종속성이 있습니다. private 생성자와 final class로 확장성을 잃도록 하고 상수 제공 역할에만 충실하도록 제한합니다. 애노테이션 프로세서보다 먼저 존재하는 값이므로 애노테이션 프로세서에도 제공 가능한 상수를 취급합니다.
enum열거형(enum)은 런타임 상수를 위한 상수 관리에 매우 적합합니다.
요청 DTO
@Email이메일 양식을 준수하도록 하는 Validation 애노테이션입니다.
@Pattern(regexp="...")작성한 정규식 패턴을 따르도록 제한하는 Validation 애노테이션입니다.
@JsonProperty("password")요청 바디에서 "password" 속성을 읽어 와 사용하겠다는 선언입니다.
응답 DTO
@JsonInclude(Include.NON_DEFAULT)가령 예제에서는 Boolean requiresMfa에 사용했고, 이는 false일 때는 응답 객체에 포함하지 않겠다는 선언입니다.
@JsonInclude(Include.NON_EMPTY)가령 예제에서는 String token에 사용했고, 이는 가 아닐 때는 응답 객체에 포함하지 않겠다는 선언입니다.
프로퍼티
app/cors.ymlCORS 설정에 사용할 커스텀 속성들입니다. WebCorsProperties.java라는 파일의 레코드 클래스에 매핑되고 있습니다. application.yml에서 import하고 있습니다.
app/jwt.ymlJWT 설정에 사용할 커스텀 속성들입니다. JwtProperties.java라는 파일의 레코드 클래스에 매핑되고 있습니다. application.yml에서 import하고 있습니다.
app/password-encoder.yml비밀번호 인코더에 사용할 커스텀 속성들입니다. 일부만 작성했습니다. PasswordEncoderProperties.java의 레코드 클래스에 매핑되고 있습니다. application.yml에서 import하고 있습니다.
~Properties속성 파일에서 가져온 데이터를 매핑하기 위한 레코드 클래스들입니다. 애노테이션은@ConfigurationProperties(prefix = "a.b.c"), @ConfigurationPropertiesBinding 등의 애노테이션이 필요합니다. 레코드 클래스로 작성하므로, 소괄호가 없는 생성자에서 값이 null인 경우 다른 값을 대입함으로써 기본값 설정을 할 수 있습니다. 중첩되는 하위 객체 표현은 @NestedConfigurationProperties, 목록 표현은 배열로 해서 YAML 지원에 관한 현재 호환성에 맞추는 등 작업이 포함되어 있습니다.
설정
WebConfigCORS 등을 설정합니다.
SecurityConfig시큐리티 필터 체인은 이 프로젝트에 용이한 범위로 했습니다. 비밀번호 인코더, AuthenticationManager 등의 빈(Bean)을 등록합니다.
ScanningPropertiesConfig@ConfigurationPropertiesScan(basePackages = Constants.BASE_PACKAGE) 애노테이션을 통해 프로퍼티 파일을 스캔할 범위를 설정 파일에서 결정하도록 합니다.
JwtConfig우리가 작성한 시크릿 키를 반영하여 JWT Parser 빈(Bean)을 생성해 등록하고 있습니다. 기존에 Deprecated 된 것들을 새로운 것으로 대체하는 방식 중 하나입니다.
로컬 환경 공유
docker-compose.yml편하게 데이터베이스 구축을 공유하기 위해 제공해 드렸습니다. 이때 멤버 테이블과 기본 데이터 한 튜플이 입력됩니다. DB를 직접 설치해서 다음 SQL 파일들을 실행하셔도 되지만, DB를 직접 설치하지 않고 docker-compose 명령어 하나로 구축할 수 있으니 권장드립니다. 자세한 내용은 Docker Compose를 확인해 주십시오.
db/initdb.d/*.sqlDB 구축에 필요한 DDL과 기본 데이터 삽입을 위한 DML입니다. Docker Compose를 수행하시면 자동으로 실행됩니다.
환경 변수
${변수명:대체값}환경 변수를 심어서 사용하는 경우 그 내용을 사용하도록 했습니다. 다만 로컬에서 작업하는 사람들끼리는 그냥 대체값으로 공유해도 되니, 대체값 자리도 임의로 채워 두었습니다.

비밀번호 접두사(명시 필요)

{bcrypt}, {scrypt}, {pbkdf2} 등 유명한 비밀번호 인코더들이 있습니다.

이것을 비밀번호 보존 시 앞에 붙여서 사용하여야 합니다.

기존에는 {bcrypt}를 생략하여도 기본값으로 인식하였지만, 이제 {bcrypt}에 대해서도 접두사를 요구할 수 있습니다.

변경이 된 지 얼마 되지 않은 시점이라, 생각보다 변화를 인지하지 못한 분들이 많이 계신 듯합니다. 관련 질의응답도 발견되지 않다 보니, 몇 가지 추리와 다른 정보들을 짜깁기해서 유추했습니다. 다행히 올바른 유추였습니다.

관련 오류 메시지는 다음과 같이 시작하고, 500 Internal Server Error로 응답합니다.

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id \"null\"\n\tat org.springframework.security.crypto.password.DelegatingPasswordEncoder$UnmappedIdPasswordEncoder.matches(DelegatingPasswordEncoder.java:289)

Docker Compose

깃허브에 올려둔 프로젝트의 루트 경로에 포함된 도커 컴포즈 파일의 내용입니다.

version: "3"

services:
  example_postgres14_proto:
    image: postgres:14
    container_name: example_postgres14_rdb_proto
    environment:
      TZ: Asia/Seoul
      POSTGRES_DB: "${POSTGRES_DBNAME}"
      POSTGRES_USER: "${POSTGRES_USERNAME}"
      POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
      POSTGRES_INITDB_ARGS: '--encoding=UTF-8 --lc-collate=C --lc-ctype=C'
    ports:
      - 5432:5432
    volumes:
      - jwtdemo_sticky_volume:/var/lib/postgresql/data
      - ./db/initdb.d:/docker-entrypoint-initdb.d:ro
    env_file:
      - .env

volumes:
  jwtdemo_sticky_volume:

볼륨 컨테이너도 써서 드렸습니다(jwtdemo_sticky_volume으로 명명).

도커 컴포즈를 사용할 줄 안다면 프로젝트 루트에서 다음을 실행하면 됩니다.

사용할 줄 모른다면, Docker Desktop을 설치하면 Docker Compose가 함께 설치됩니다. 설치 후에는 기존에 열려 있던 터미널 창을 닫고 새로운 터미널 창을 열어서 프로젝트 루트에서 다음을 수행하면 됩니다.

docker-compose up -d --build

만약 프로젝트 루트에서 실행하는 방법을 모른다면 이렇게 하시면 됩니다. 만약 프로젝트의 경로가 D:\workspace-group\intellij-workspace\demo-jwt라면

D:
cd D:\workspace-group\intellij-workspace\demo-jwt

이후 드디어 docker-compose.yml 파일이 포함된 프로젝트 루트에 당도하였으니

docker-compose up -d --build

Secret Key

환경 변수를 통해 제공하도록 작성해 두었습니다.

app:
  jwt:
    secret: ${jwt.secret:(대체할 값)}

이 PC에 해당 환경변수가 없을 때 대체할 값을 (대체할 값) 쪽에 소괄호, 앞뒤 공백 없이 적어 두면 PC마다 환경변수를 안 만들어도 그 값으로 알아서 공유됩니다. 로컬 환경은 사람마다 환경 변수를 심고 다니기 귀찮으니, 적당히 지어도 되는 값들은 그렇게 공유합니다.

각 역할

Authentication Manager

SecurityConfig.java 등에서 작성합니다. 제공되는 기본 객체를 사용했습니다.

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration)
        throws Exception {
    return authenticationConfiguration.getAuthenticationManager();
}

UserDetailsService

저는 AuthenticationUserMappingService.java라는 이름으로, UserDetailsService 인터페이스를 구현받아 사용했습니다.

얘한테 회원 데이터 담는 로직을 심어 주면, AuthenticationManager가 얘한테 정보를 받아서 쓰나 보다 생각하시면 됩니다. 어디서 연결되냐고 물으신다면, 우리 눈에 안 보이는 데서 이것저것 여차저차 해 주는 게 프레임워크의 묘미 아니겠습니까.

// ... 일부 생략
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

@Service
@RequiredArgsConstructor
public final class AuthenticationUserMappingService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Optional<AuthenticationProjection> optionalAuthentication = memberRepository.findByEmail(email);
        if (optionalAuthentication.isEmpty()) throw AuthenticationErrorCode.MEMBER_MISSING.defaultException();

        AuthenticationProjection authInfo = optionalAuthentication.get();

        return User
                .withUsername(email)
                .password(authInfo.password()) // 얘가 {bcrypt}로 시작합니다.
                .authorities("USER") // 얘는 현 단계에서는 미구현이죠.
                .accountExpired(false)
                .accountLocked(false)
                .credentialsExpired(false)
                .disabled(false)
                .build();
    }
}

JWT Provider

JWT 인증 토큰 공급자입니다.

다른 예제 소스에 비해서 어려워 보이는 byte[]로 담아다가 쓰는 작업이나 Keys.hmacShaKeyFor(...) 등은 잘난 척하려고 쓴 게 아니라, 얘네가 이제 이렇게 쓰라고 강매를 합니다. 시대가 바뀐 것입니다.

JwtProperties 등은 속성 파일을 매핑한 자바 클래스/객체입니다.

import com.example.demo.properties.JwtProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;

@Component
public final class JwtProvider {

    private final Key secretKey;
    private final Long expireIn;


    public JwtProvider(JwtProperties jwtProperties) {
        byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.secret());
        secretKey = Keys.hmacShaKeyFor(keyBytes);
        expireIn = jwtProperties.expireIn();
    }

    public String generate(String email) {
        return generate(email, "USER");
    }

    public String generate(String email, String... roles) {
        Claims claims = Jwts.claims().setSubject(email);

        Date now = new Date();
        Date expireAt = new Date(now.getTime() + expireIn);

        claims.put("roles", roles);

        String jwt = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expireAt)
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();

        return jwt;
    }
}

목적: 사용해 봅시다.

코드

// ... 일부 생략
import org.springframework.context.annotation.Primary;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;

@Service
@Primary
@RequiredArgsConstructor
public class DefaultAuthService implements AuthService {

    private final AuthenticationManager authenticationManager;
    private final JwtProvider jwtProvider;

    @Override
    public SignInResponseDto signIn(SignInRequestDto body) {
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(body.email(), body.rawPassword());

        // [1] 인증
        Authentication authentication = authenticationManager.authenticate(authenticationToken);
        
        // 인증이 통과돼야 이곳에 올 수 있음. 인증이 안 되면 앞서 403 Forbidden과 함께 예외가 발생.

        // [2] 토큰 발행(액세스 토큰)
        String token = jwtProvider.generate(body.email());

        // [3] 응답.
        return SignInResponseDto.builder()
                .requiresMfa(false)
                .token(token)
                .build();
    }
}

Refresh 등에서도 jwtProvider.generate(...)를 통해 Access Token을 다시 발행하면 됩니다.

사용된 신택스

Record

자바 17에서는 record 클래스를 사용할 수 있습니다. 낯설겠지만 쉽자고 나온 앱니다.
(Java 16 이상)

모든 멤버 변수의 불변성을 편하게 보장하기 위한 클래스로, 속성만 나열해도 돼서 사용은 쉽습니다.

사용 방식은 코틀린의 클래스 선언과 닮아 있습니다.

// 이렇게만 적어도 됨. 파일 이름은 MyRecord.java
public record MyRecord (String name, Integer age) {
	// 여기서 아무것도 안 해도 Getter(name(), age()), 모든 요소를 포함하는 생성자, toString(), 이퀄스/해시코드... 이런 건 포함됨.
}
  • DTO 등에 쓰세요. JPA 엔티티 등에는 쓰지 마세요. ← 불변성에 주목하십시오.
  • 불변성을 띠는 게 큰 특징입니다. 또한 상속이 없습니다.
  • 상위에는 인터페이스만 둘 수 있습니다. enum도 그렇지만 대부분 확장은 인터페이스가 유연하고 지원 범위가 넓습니다.

  • getter의 양식은 기존 컨벤션으로부터 독립하여 필드 이름만 씁니다.

  • 초기에 특수한 형태의 생성자에서 불변성 없이 사용 가능한 변수를 취급합니다. 생성된 후부터는 불변으로 고정되지만, 이 특수한 생성자에서는 아닙니다.

  • 그리고 스프링 부트 진영에서 이 record도 어지간하면 호환해 줍니다.

    public record Example (String name, Integer age) {
    	// 소괄호가 없는 생성자를 사용.
    	public Example {
        	if (name == null || name.length == 0) { name = "이름 없음"; }
            if (age == null) age = 0;
            
            name = name.trim();
    	}
    }
profile
아 성장판 쑤셔 (블로그 이전) https://letsdev.hashnode.dev
post-custom-banner

2개의 댓글

comment-user-thumbnail
2023년 6월 1일

개추

1개의 답글