[React] JWT 토큰 발급하기
JWT 토큰이란?
JWT(Json Web Token) 토큰이란 암호화된 Json 토큰으로 사용자의 인증 또는 인가 정보를 서버와 클라이언트 간에 주고받기 위해 사용됩니다
쿠키와 비슷하게 사용자가 로그인을 하면 서버가 발급한 토큰이 브라우저에 저장되고 이후 클라이언트가 해당 토큰을 서버에 제출하면 서버는 토큰이 유효한지를 검사해 로그인 여부를 확인합니다.
SpringSecurity에서 JWT 토큰이 사용되기 때문에 많이 사용됩니다
JWT 토큰의 구조
JWT 토큰은 헤더(header), 페이로드(payload), 서명(signature) 세 부분으로 이루어져 있습니다
헤더
{
"alg": "HS256",
"typ": "JWT"
}
토큰의 유형과 서명 알고리즘이 명시됩니다
페이로드
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
claim이라 불리는 사용자의 인증/인가 정보가 저장됩니다
서명
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)
헤더와 페이로드가 엔코드된 후 해시함수를 적용하고, 개인 키로 서명한 전자서명이 담깁니다
JWT 로그인을 위한 OAuth2 Resoure Server 설정
1. Edit Starters에서 Dependencies에 OAuth2 Resource Server를 추가합니다
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
2. 개인키와 공개키를 저장할 디렉토리를 만들고 .gitignore에 추가합니다
이는 개인키와 공개키가 노출되는 것을 방지하기 위함입니다
/src/main/resources/secret/
3. 생성한 디렉토리에 개인키/공개키 생성
openssl genrsa > app.key : OpenSSL 명렁어인 openssl genrsa로 개인 키를 생성하고 app.key에 저장합니다
openssl rsa -pubout -in app.key -out app.pub : app.key에서 공개 키를 추출하고 app.pub에 저장합니다
생성된 개인키와 공개키는 토큰 해독에 사용됩니다
4. application.properties에 값 주입
jwt.public.key=classpath:secret/app.pub
jwt.private.key=classpath:secret/app.key
외부에서 사용할 수 있도록 application.properties에 값을 주입합니다
5. Configuration 설정
@Value("${jwt.public.key}")
RSAPublicKey key;
@Value("${jwt.private.key}")
RSAPrivateKey priv;
@Value 어노테이션으로 application.properties에 주입된 값을 받아옵니다
@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(this.key).build();
}
@Bean
JwtEncoder jwtEncoder() {
JWK jwk = new RSAKey.Builder(this.key).privateKey(this.priv).build();
JWKSource<SecurityContext> jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
return new NimbusJwtEncoder(jwks);
}
jwtDecoder와 jwtEncoder의 빈을 주입받습니다
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer
.jwt(withDefaults()));
return http.build();
}
securityFilterChain에서 JWT 방식을 사용해서 토큰을 검증하는 설정을 합니다
JWT 토큰 발급하고 로그인하기
/api/login으로 요청을 보내면 Controller가 username을 포함한 토큰을 발급하고 전송받은 토큰을 localStorage에 저장합니다
function App(props) {
const [username, setUsername] = useState("son");
function handleLogin() {
axios.post("/api/login", { username }).then((res) => {
localStorage.setItem("token", res.data);
});
}
function handleLogout() {
localStorage.removeItem("token");
}
return (
<div>
<div>
<input
type="text"
defaultValue={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<button onClick={handleLogin}>로그인</button>
<button onClick={handleLogout}>로그아웃</button>
</div>
</div>
);
- input 태그로 username을 입력받습니다
- 로그인 버튼을 누르면 /api/login 경로로 username과 함께 요청을 보냅니다
- localStrage.setItem("token", res.data) : 응답 결과인 토큰을 localStrage에 저장합니다
- 로그아웃 버튼을 누르면 localStorage.romoveItem("token")으로 토큰을 저장된 토큰을 제거합니다
@Controller
@RequestMapping("/api")
@RequiredArgsConstructor
public class Controller44 {
private final JwtEncoder jwtEncoder;
@PostMapping("login")
@ResponseBody
public String login(@RequestBody Map<String, Object> map) {
String username = map.get("username").toString();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("self")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(60 * 60 * 24))
.subject(username)
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
}
- JwtClaimSet.builder() : JwtClaimSet 객체를 생성하기 위한 빌더를 시작합니다
- issuer("self") : 클레임의 발행자(iss) 필드를 "self"로 설정합니다
- issuedAt(Instant.now()) : 클레임의 발행 시간(iat) 필드를 현재 시간으로 설정합니다
- expiresAt(Instant.now().plusSeconds(60 * 60 * 24) : 크레임의 만료 시간(exp) 필드를 현재시간으로부터 24시간 후로 설정합니다
- subject(username) : 클레임의 주제(sub) 필드를 username으로 설정합니다
- build() : 설정된 클레임을 바탕으로 JwtClaimSet 객체를 생성합니다
- 생성한 JwtClaimSet 객체를 사용하여 토큰을 발급합니다
로그인 여부에 따라 접근 가능한 경로 작성하기
@GetMapping("user")
@ResponseBody
@PreAuthorize("isAuthenticated()")
public String user() {
return "로그인 한 유저만 접근 가능한 경로";
}
@PreAuthorize("isAuthenticated()") 어노테이션으로 로그인 한(토큰이 유효한) 유저만 접근 가능하게 설정할 수 있습니다
토큰 발급시 권한 부여하고 권한별 접근 가능한 경로 작성하기
@PostMapping("login")
@ResponseBody
public String login(@RequestBody Map<String, Object> map) {
String username = map.get("username").toString();
String scope = map.get("scope").toString();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("self")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(60 * 60 * 24))
.subject(username)
.claim("scope", scope) // 권한
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
- username과 마찬가지로 scope 값을 받아옵니다
- claim("scope", scope) : 클레임의 이름을 "scope"로 지정합니다. 이 클레임은 주로 OAuth 2.0에서 사용되는 스코프(scope, 접근 권한 범위)값을 나타냅니다. 클레임의 값에 scope 값을 넣습니다
@GetMapping("admin")
@ResponseBody
@PreAuthorize("hasAuthority('SCOPE_admin')")
public String admin() {
return "어드민만 접근 가능한 경로";
}
@GetMapping("manager")
@ResponseBody
@PreAuthorize("hasAuthority('SCOPE_manager')")
public String manager() {
return "매니저만 접근 가능한 경로";
}
@GetMapping("ma")
@ResponseBody
@PreAuthorize("hasAnyAuthority('SCOPE_manager', 'SCOPE_admin')")
public String ma() {
return "매니져 또는 어드민이 접근 가능한 경로";
}
발급받은 토큰을 가져왔을 때 접근 가능한 경로를 설정합니다
@PreAuthorize("hasAuthority('SCOPE_admin')") : SCOPE_admin 권한을 가진 사람만 접근할 수 있게 하는 어노테이션입니다. SCOPE_admin은 토큰 Claim 중 scp의 값이 admin인 값인 것을 말합니다