์ตœ๊ทผ ์‹œ์Šคํ…œ์—์„œ ๋ฐœ์†ก๋˜๋Š” ํŠน์ • ์ด๋ฉ”์ผ์— ํฌํ•จ๋˜๋Š” ๋งํฌ๋ฅผ ํ†ตํ•ด์„œ ์‚ฌ์šฉ์ž๊ฐ€ ๊ด€๋ จ๋œ ํŽ˜์ด์ง€์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•ด๋‹ฌ๋ผ๋Š” ์š”๊ตฌ์‚ฌํ•ญ์ด ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ์ผ๋ฐ˜์ ์œผ๋กœ ์‚ฌ์šฉ์ž๊ฐ€ ์ด๋ฉ”์ผ์„ ํ†ตํ•ด์„œ ์‹œ์Šคํ…œ์— ์ ‘์†ํ•˜๋Š” ๊ฒฝ์šฐ์—๋Š” ์ž์‹ ์˜ ๊ณ„์ •๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‹œ์Šคํ…œ์— ์ธ์ฆํ•˜๊ธฐ ์ „์ธ ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ์ด๋ฉ”์ผ์— ํฌํ•จ๋œ ๋งํฌ๋ฅผ ํ†ตํ•ด์„œ ์ ‘๊ทผํ•  ๋•Œ ์‚ฌ์šฉ์ž ์ธ์ฆ์„ ์ผ์‹œ์ ์œผ๋กœ ์ œ๊ณตํ•˜๋Š” ๋ฐฉ์•ˆ์„ ๋„์ž…ํ•ด์•ผํ•ฉ๋‹ˆ๋‹ค.

Token-based Authentication

ํ† ํฐ ๊ธฐ๋ฐ˜ ์ธ์ฆ ๋งค์ปค๋‹ˆ์ฆ˜์€ ์‚ฌ์šฉ์ž ๊ณ„์ •๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜์ง€ ์•Š์•„๋„ ์‹œ์Šคํ…œ์„ ์ด์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๊ถŒํ•œ์„ ์ œ๊ณตํ•˜๊ธฐ ์œ„ํ•œ ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ ํ† ํฐ์ด๋ผํ•จ์€ ์‹œ์Šคํ…œ์ด ์ธ์‹ํ•  ์ˆ˜ ์žˆ๋Š” ๋ฌธ์ž์—ด ๋ฐ์ดํ„ฐ๋ฅผ ๋งํ•ฉ๋‹ˆ๋‹ค. ํœด๋Œ€ํฐ ๋ฌธ์ž ์ธ์ฆ์ด๋‚˜ ์ด๋ฉ”์ผ๋กœ ๋ฐœ์†ก๋˜๋Š” ์ธ์ฆ์ฝ”๋“œ ๋˜๋Š” ๋งํฌ๋ฅผ ํ† ํฐ์ด๋ผ ๋ถ€๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํ˜„์žฌ ์‹œ์Šคํ…œ์€ Spring Security OAuth๋ฅผ ํ†ตํ•ด ํด๋ผ์ด์–ธํŠธ ํฌ๋ ˆ๋ด์…œ ๊ธฐ๋ฐ˜์˜ ํ† ํฐ์œผ๋กœ OpenAPI๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ์ œ๊ณตํ•˜๊ณ  ์žˆ๋Š”๋ฐ JWT๊ฐ€ ์•„๋‹Œ JdbcTokenStore๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๊ธฐ์— SecureRandomBytesKeyGenerator๋กœ ๋งŒ๋“ค์–ด์ง€๋Š” ์•ก์„ธ์Šค ํ† ํฐ์„ ์‚ฌ์šฉํ•˜๊ฒŒ ๋˜์–ด์žˆ์Šต๋‹ˆ๋‹ค. ๊ฐœ๋ฐœ์ž ์ปค๋ฎค๋‹ˆํ‹ฐ๋ฅผ ๋ณด๋ฉด ์„ธ์…˜ ๊ธฐ๋ฐ˜ ์ธ์ฆ ๋งค์ปค๋‹ˆ์ฆ˜์ด ์•„๋‹Œ ํ† ํฐ ๊ธฐ๋ฐ˜์œผ๋กœ JWT๋ฅผ ๋ฐœ๊ธ‰ํ•˜๊ณ  ์„œ๋ฒ„์™€ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ํ†ต์‹ ํ•  ๋•Œ JWT๋ฅผ ์š”์ฒญ ํ—ค๋”์— ํฌํ•จ์‹œ์ผœ ์‹œ์Šคํ…œ์— ๋Œ€ํ•œ ๊ถŒํ•œ ๋ฐ ์ธ๊ฐ€๋ฅผ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ตฌ์„ฑํ•˜๋Š” ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.

JWT

RFC7519๋กœ ์ •์˜๋˜์–ด์žˆ๋Š” JWT(JSON Web Token)์€ ์‚ฌ์šฉ์ž์˜ ์‹ ์›์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ์ •๋ณด๋ฅผ Base64 ์ธ์ฝ”๋”ฉ์œผ๋กœ ํ‘œํ˜„ํ•˜๋ฉด์„œ ํŽ˜์ด๋กœ๋“œ์— ๋Œ€ํ•œ ์ „์ž์„œ๋ช…์„ ํ†ตํ•ด์„œ ์ธ์ฆ ์‹œ์Šคํ…œ์—์„œ ๋ฐœ๊ธ‰ํ•œ ํ† ํฐ์„ ์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๋Š”์ง€ ๊ฒ€์ฆํ•˜์—ฌ ํ† ํฐ์— ํฌํ•จ๋œ ์ •๋ณด๋ฅผ ํ† ๋Œ€๋กœ ๊ถŒํ•œ ๋ฐ ์ธ๊ฐ€๋ฅผ ์ ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. JWT์— ๋Œ€ํ•ด์„œ๋Š” ์•„๋ž˜์˜ ๋งํฌ๋ฅผ ํ†ตํ•ด์„œ ๊ฐ„๋‹จํ•˜๊ฒŒ ์ดํ•ดํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

URI Query String

JSON Web Token (JWT) is a compact claims representation format intended for space constrained environments such as HTTP Authorization headers and URI query parameters.

์ด๋ฉ”์ผ์— ํฌํ•จ๋˜๋Š” ๋งํฌ๋Š” GET ์š”์ฒญ์„ ์ˆ˜ํ–‰ํ•˜๋Š” URL(URI + Query String)์ด๋ฏ€๋กœ ์ผ๋ฐ˜์ ์œผ๋กœ HTTP ํ†ต์‹  ์‹œ Authorization ๋˜๋Š” X-Auth-Token ํ—ค๋”์— ํ† ํฐ์„ ํฌํ•จํ•˜์—ฌ ์ „๋‹ฌํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. JWT๋Š” ๊ทธ ์ž์ฒด๋กœ Base64 URL Safe๋กœ ์ธ์ฝ”๋”ฉ๋˜๋ฏ€๋กœ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ์— ํฌํ•จํ•˜์—ฌ ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Token Parameter

์ด๋ฉ”์ผ์— ํฌํ•จ๋˜๋Š” ๋งํฌ์— ํ† ํฐ ์ •๋ณด๊ฐ€ ์–ด๋–ป๊ฒŒ ๋‹ด๊ฒจ์ง€๋Š”์ง€ ๋‘๊ฐ€์ง€ ์˜ˆ์‹œ๋ฅผ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค. ์ฒซ๋ฒˆ์งธ๋Š” ์ด๋ฉ”์ผ๊ณผ ์ธ์ฆ์„ ์œ„ํ•œ ํ† ํฐ์ด Path Variable ํ˜•ํƒœ๋กœ URI์— ํฌํ•จ๋˜๋Š” ๋ฐฉ์‹์ด๋ฉฐ, ๋‘๋ฒˆ์งธ๋Š” URI์— ๋Œ€ํ•œ ์ฟผ๋ฆฌ ์ŠคํŠธ๋ง์œผ๋กœ ํ† ํฐ ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ํฌํ•จ๋จ์„ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค.

๋‘๋ฒˆ์งธ์ฒ˜๋Ÿผ ์ด๋ฉ”์ผ์— ํฌํ•จ๋œ ๋งํฌ๋ฅผ ๋‚˜์ค‘์— ํด๋ฆญํ•˜๋”๋ผ๋„ ์‹œ์Šคํ…œ์—์„œ ์ธ์‹ํ•  ์ˆ˜ ์žˆ๋Š” ํ† ํฐ์ธ์ง€ ๋งŒ๋ฃŒ๋˜์—ˆ๋Š”์ง€๋ฅผ ํŒ๋‹จํ•˜์—ฌ ์‹œ์Šคํ…œ์— ๋Œ€ํ•œ ์ธ๊ฐ€๋ฅผ ํŒ๋‹จํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋“ฏ์ด ์ด๋ฉ”์ผ์—๋Š” ์‹œ์Šคํ…œ์—์„œ ๋ฐœ๊ธ‰ํ•œ ํ† ํฐ์ด ๋งํฌ์— ํฌํ•จ๋˜์–ด ์œ ์ง€๋˜๋ฏ€๋กœ ๋ณด์•ˆ ์ƒ ๋งŒ๋ฃŒ ์‹œ๊ฐ„์„ ์ตœ์†Œํ™”ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

Implementation Sample

์ด๋ฉ”์ผ ๋งํฌ์— ํฌํ•จ๋œ ํ† ํฐ ํŒŒ๋ผ๋ฏธํ„ฐ์— ๋”ฐ๋ผ ํ† ํฐ ๊ธฐ๋ฐ˜ ์ธ์ฆ ๋งค์ปค๋‹ˆ์ฆ˜์„ ์ˆ˜ํ–‰ํ•˜๊ณ  ์‚ฌ์šฉ์ž ์ธ์ฆ์„ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ์•ˆ์„ ์ ์šฉํ•ด๋ณด๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๊ฐ„๋‹จํ•œ ์ƒ˜ํ”Œ ํ˜•ํƒœ๋กœ ๋งŒ๋“ค๊ณ  ์ผ๋ จ์˜ ํ”„๋กœ์„ธ์Šค๋ฅผ ์ดํ•ดํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ณต์œ ํ•˜๊ณ ์žํ•จ์ด๋‹ˆ ์ฝ”๋“œ๋ฅผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•˜๊ธฐ์—๋Š” ๋ฌธ์ œ์ ์ด ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Dependencies

์šฐ์„  ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ ๊ธฐ๋ฐ˜์˜ ํ™˜๊ฒฝ์ด๋ผ๋Š” ๊ฒƒ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ํ•˜๋ฉฐ JWT ๋ฐœ๊ธ‰๊ณผ ๊ฒ€์ฆ์„ ์œ„ํ•ด์„œ ์‚ฌ์šฉํ•  Java JWT ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์™€ JWT ๋ฐœ๊ธ‰ ์‹œ ์„œ๋ช…ํ•  ํ‚ค ํŽ˜์–ด๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์žˆ๋„๋ก Bouncy Castle๋ฅผ ์˜์กด์„ฑ์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

dependencies {
    implementation 'com.google.code.gson:gson:2.9.0'
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    implementation 'org.bouncycastle:bcprov-jdk18on:1.71'
}

Generate EC Key Pair

๋ณธ ์ƒ˜ํ”Œ์—์„œ๋Š” Cryptographic Algorithms for Digital Signatures and MACs ๋ชฉ๋ก ์ค‘์—์„œ ES256์ด๋ผ๋Š” ์„œ๋ช… ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ์‚ฌ์šฉํ•˜๋Š” JWT ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•˜๊ธฐ ์œ„ํ•ด P-256 ๊ณก์„ ์„ ์‚ฌ์šฉํ•˜๋Š” EC ํ‚ค ํŽ˜์–ด๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

openssl ecparam -name prime256v1 -genkey -noout -out ec-private.pem
openssl ec -in ec-private.pem -pubout -out ec-public.pem
openssl pkcs8 -topk8 -inform pem -in ec-private.pem -outform pem -nocrypt -out ec-private.pkcs8

Generate JWT

JWT ๋ฐœ๊ธ‰์„ ์œ„ํ•œ EC ํ‚ค ํŽ˜์–ด๋ฅผ ์ค€๋น„ํ–ˆ์œผ๋ฏ€๋กœ ํด๋ž˜์ŠคํŒจ์Šค๋กœ๋ถ€ํ„ฐ ํ‚ค ํŽ˜์–ด๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๊ณ  ํ† ํฐ ๋ฐœ๊ธ‰์„ ์œ„ํ•œ ์œ ํ‹ธ ํด๋ž˜์Šค๋ฅผ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

package com.example.crypto.util;

import io.jsonwebtoken.SignatureAlgorithm;
import org.bouncycastle.util.io.pem.PemReader;
import org.springframework.core.io.ClassPathResource;
import org.springframework.util.StreamUtils;

import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.KeyFactory;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class KeyUtil {
    private KeyUtil() {
    }

    private static final Map<String, Key> signingKeyStore = new ConcurrentHashMap<>();
    private static final Map<String, Key> parseKeyStore = new ConcurrentHashMap<>();


    public static Key parseKey(SignatureAlgorithm signatureAlgorithm) {
        Key key = null;
        if (parseKeyStore.containsKey(signatureAlgorithm.name())) {
            key = parseKeyStore.get(signatureAlgorithm.name());
        }

        if (key != null) return key;

        try {
            String content;
            PemReader pemReader;
            X509EncodedKeySpec spec;
            KeyFactory kf;

            switch (signatureAlgorithm) {
                case HS256:
                    content = StreamUtils.copyToString(new ClassPathResource("jwt/hs256/secret.key").getInputStream(), StandardCharsets.UTF_8);
                    key = new SecretKeySpec(DatatypeConverter.parseBase64Binary(content), signatureAlgorithm.getJcaName());
                    break;
                case RS256:
                    content = StreamUtils.copyToString(new ClassPathResource("jwt/rs256/rsa-public.pem").getInputStream(), StandardCharsets.UTF_8);
                    pemReader = new PemReader(new StringReader(content));
                    spec = new X509EncodedKeySpec(pemReader.readPemObject().getContent());
                    kf = KeyFactory.getInstance("RSA");
                    key = kf.generatePublic(spec);
                    break;
                case ES256:
                    content = StreamUtils.copyToString(new ClassPathResource("jwt/es256/ec-public.pem").getInputStream(), StandardCharsets.UTF_8);
                    pemReader = new PemReader(new StringReader(content));
                    spec = new X509EncodedKeySpec(pemReader.readPemObject().getContent());
                    kf = KeyFactory.getInstance("EC"); // Elliptic Curve
                    key = kf.generatePublic(spec);
                    break;
                default:
                    throw new UnsupportedOperationException("Only support HS256, RS256, ES256");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        parseKeyStore.put(signatureAlgorithm.name(), key);
        return key;
    }

    public static Key signingKey(SignatureAlgorithm signatureAlgorithm) {
        Key key = null;
        if (signingKeyStore.containsKey(signatureAlgorithm.name())) {
            key = signingKeyStore.get(signatureAlgorithm.name());
        }

        if (key != null) return key;

        try {
            String content;
            PemReader pemReader;
            PKCS8EncodedKeySpec spec;
            KeyFactory kf;

            switch (signatureAlgorithm) {
                case HS256:
                    content = StreamUtils.copyToString(new ClassPathResource("jwt/hs256/secret.key").getInputStream(), StandardCharsets.UTF_8);
                    key = new SecretKeySpec(DatatypeConverter.parseBase64Binary(content), signatureAlgorithm.getJcaName());
                    break;
                case RS256:
                    content = StreamUtils.copyToString(new ClassPathResource("jwt/rs256/rsa-private.pem").getInputStream(), StandardCharsets.UTF_8);
                    pemReader = new PemReader(new StringReader(content));
                    spec = new PKCS8EncodedKeySpec(pemReader.readPemObject().getContent());
                    kf = KeyFactory.getInstance("RSA");
                    key = kf.generatePrivate(spec);
                    break;
                case ES256:
                    content = StreamUtils.copyToString(new ClassPathResource("jwt/es256/ec-private.pkcs8").getInputStream(), StandardCharsets.UTF_8);
                    pemReader = new PemReader(new StringReader(content));
                    spec = new PKCS8EncodedKeySpec(pemReader.readPemObject().getContent());
                    kf = KeyFactory.getInstance("EC"); // Elliptic Curve
                    key = kf.generatePrivate(spec);
                    break;
                default:
                    throw new UnsupportedOperationException("Only support HS256, RS256, ES256");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        signingKeyStore.put(signatureAlgorithm.name(), key);
        return key;
    }

}
package com.example.crypto.util;

import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import io.jsonwebtoken.*;

import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.time.ZonedDateTime;
import java.util.*;

public class JwtUtil {
    private static final String REGEX = "^[A-Za-z0-9-_=]+\\.[A-Za-z0-9-_=]+\\.?[A-Za-z0-9-_.+/=]*$";
    private static final Gson gson = new Gson();
    private static final Type hashMapType = new TypeToken<HashMap<String, Object>>() {
    }.getType();

    private JwtUtil() {
    }

    public static boolean isJwt(String token) {
        return token != null && !token.isBlank() && token.matches(REGEX);
    }

    public static boolean isValid(String token) {
        if (!isJwt(token)) return false;
        return isValid(token, alg(token));
    }

    public static boolean isValid(String token, String alg) {
        Key publicKey = KeyUtil.signingKey(SignatureAlgorithm.forName(alg));
        JwtParser parser = Jwts.parserBuilder().setSigningKey(publicKey).build();
        return parser.isSigned(token);
    }

    private static String alg(String token) {
        if (!isJwt(token)) return null;

        String[] chunks = token.split("\\.");
        String header = new String(Base64.getUrlDecoder().decode(chunks[0]), StandardCharsets.UTF_8);
        Map<String, Object> headerMap = gson.fromJson(header, hashMapType);
        return (String) headerMap.get("alg");
    }

    public static String generate(String subject, long expires, Map<String, Object> claims, SignatureAlgorithm signatureAlgorithm) {
        long issuedAt = ZonedDateTime.now(TimeZone.getTimeZone("UTC").toZoneId()).toInstant().toEpochMilli();
        long expiration = issuedAt + expires;

        try {
            Key signingKey = KeyUtil.signingKey(signatureAlgorithm);
            if (signingKey == null) throw new JwtException("Not found signingKey for " + signatureAlgorithm.name());
            return Jwts.builder()
                    .setHeaderParam("typ", "JWT")
                    .setIssuer("JWT Sample Issuer")
                    .setSubject(subject)
                    .setIssuedAt(new Date(issuedAt))
                    .setExpiration(new Date(expiration))
                    .signWith(signingKey, signatureAlgorithm)
                    .addClaims(claims)
                    .compact();
        } catch (Exception e) {
            e.printStackTrace();
        }

        throw new UnsupportedJwtException("Cannot generate jwt");
    }

    public static Jws<Claims> parseClaims(String token) {
        return parseClaims(token, alg(token));
    }

    public static Jws<Claims> parseClaims(String token, String alg) {
        Key publicKey = KeyUtil.signingKey(SignatureAlgorithm.forName(alg));
        JwtParser parser = Jwts.parserBuilder().setSigningKey(publicKey).build();
        return parser.parseClaimsJws(token);
    }

}

Add Token Filter in SecurityFilterChain

์‹œ์Šคํ…œ์—์„œ ๋ฐœ๊ธ‰๋œ ํ† ํฐ์ด ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ „๋‹ฌ๋˜์—ˆ์„ ๋•Œ JWT๋ฅผ ๊ฒ€์ฆํ•˜๊ณ  ์‚ฌ์šฉ์ž ์ธ์ฆ์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ํ•„ํ„ฐ๋ฅผ ์ž‘์„ฑํ•˜๊ณ  ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ ํ•„ํ„ฐ ์ฒด์ธ์— ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค.

TokenAuthenticationFilter.java

package com.example.crypto.filter;

import com.example.crypto.util.JwtUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;

@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {

    private static final String ADDITIONAL_URI_PATTERN = "/users/{username}";
    private final AntPathMatcher pathMatcher = new AntPathMatcher();

    private final UserDetailsService userDetailsService;

    public TokenAuthenticationFilter(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = request.getParameter("token");
        if (JwtUtil.isJwt(token)) {
            String requestURI = request.getRequestURI();
            String base64RequestURI = Base64.getUrlEncoder().encodeToString(requestURI.getBytes(StandardCharsets.UTF_8));
            String redirectLoginUrl = "/login?redirect=" + base64RequestURI;

            boolean isValidToken;
            try {
                isValidToken = JwtUtil.isValid(token);
                if (isValidToken) {
                    Jws<Claims> claims = JwtUtil.parseClaims(token);
                    Claims body = claims.getBody();
                    String subject = body.getSubject();

                    if (pathMatcher.match(ADDITIONAL_URI_PATTERN, requestURI)) {
                        isValidToken = isValidWithVariables(body, requestURI);
                    }

                    if (isValidToken) {
                        UserDetails userDetails = userDetailsService.loadUserByUsername(subject);
                        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, token, userDetails.getAuthorities());
                        SecurityContext securityContext = SecurityContextHolder.getContext();
                        securityContext.setAuthentication(authentication);
                        response.sendRedirect(requestURI);
                    }
                }
            } catch (Exception e) {
                if (e instanceof JwtException) {
                    e.printStackTrace();
                }
                isValidToken = false;
            }

            if (!isValidToken) {
                // NOTE: If token expired or invalid, redirect for login page.
                response.sendRedirect(redirectLoginUrl);
            }
        } else {
            filterChain.doFilter(request, response);
        }
    }

    private boolean isValidWithVariables(Claims body, String requestURI) {
        Map<String, String> variables = pathMatcher.extractUriTemplateVariables(ADDITIONAL_URI_PATTERN, requestURI);
        String userId = body.get("username", String.class);
        return variables.containsKey("username") && variables.get("username").equals(userId);
    }
}

SecurityConfig.java
๊ฐ„๋‹จํ•œ ์ƒ˜ํ”Œ์ด๋ฏ€๋กœ ์ธ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ์ž๋ฅผ ๋“ฑ๋กํ•˜๋Š” ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ ํ™˜๊ฒฝ์„ ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค.

package com.example.crypto.config;

import com.example.crypto.filter.TokenAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    // https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/in-memory.html
    @Bean
    public UserDetailsService users() {
        UserDetails mambo = User.builder()
                .username("mambo")
                .password("{noop}1234")
                .roles("USER", "ADMIN")
                .build();
        return new InMemoryUserDetailsManager(mambo);
    }

    // https://docs.spring.io/spring-security/reference/servlet/configuration/java.html#jc-httpsecurity
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, TokenAuthenticationFilter tokenAuthenticationFilter) throws Exception {
        http.formLogin()
                .and().authorizeRequests().antMatchers("/users/{username}/**").authenticated()
                .and().addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

Generate JWT Test

ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ํ†ตํ•ด์„œ ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•ด๋ณด๊ณ  ์ด๋ฉ”์ผ์— ํฌํ•จ๋  ๋งํฌ์ฒ˜๋Ÿผ ํ† ํฐ ํŒŒ๋ผ๋ฏธํ„ฐ์— ํ† ํฐ์„ ํฌํ•จํ•˜์—ฌ ๋ธŒ๋ผ์šฐ์ € ์ฃผ์†Œ์— ์ง์ ‘ ์ž…๋ ฅํ•ด๋ด…๋‹ˆ๋‹ค.

@Slf4j
class JwtUtilTest {
    @Test
    void generateToken() {
        Assertions.assertDoesNotThrow(() -> {
            String subject = "mambo";
            long expires = Duration.ofMinutes(3L).toMillis();
            Map<String, Object> claims = new HashMap<>();
            claims.put("username", "mambo");
            String jwt = JwtUtil.generate(subject, expires, claims, SignatureAlgorithm.ES256);
            log.info("\nmambo.kr:8080/users/mambo?token={}", jwt);
        });
    }
}

ํ† ํฐ์ด ๋ฐœ๊ธ‰๋˜๊ณ ๋‚˜์„œ 3๋ถ„ ์ด๋‚ด์—๋Š” ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ๊ฐ€ ๋จ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ๋งŒ๋ฃŒ๋œ ์ดํ›„๋ผ๋ฉด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์„œ๋ช…๋œ ํ† ํฐ์ผ์ง€๋ผ๋„ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋””๋ ‰์…˜๋จ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋ณธ ๊ธ€์—์„œ๋Š” JWS์œผ๋กœ๋งŒ ๊ตฌ์„ฑ๋œ JWT ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•˜์—ฌ ์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐœ๊ธ‰์ž๋กœ๋ถ€ํ„ฐ ์„œ๋ช…๋œ ๊ฒƒ์ž„์„ ์ฆ๋ช…ํ•˜๋Š” ๊ฒƒ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์‚ฌ์šฉ์ž ์ธ์ฆ์„ ์ˆ˜ํ–‰ํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, JWT๋Š” ์•”ํ˜ธํ™”๋˜์ง€ ์•Š์€ ์ƒํƒœ๋กœ ์ด๋ฉ”์ผ๊ณผ ๊ฐ™์€ ๊ณณ์— ๋…ธ์ถœ๋˜์–ด์žˆ์œผ๋ฏ€๋กœ ์งง์€ ๋งŒ๋ฃŒ ์‹œ๊ฐ„์„ ๋‘์–ด ์ œํ•œ ์‹œ๊ฐ„๋‚ด์—๋งŒ ์ธ์ฆํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜์˜€์Šต๋‹ˆ๋‹ค.
์ข€ ๋” ์‹œ๊ฐ„์ด ์ฃผ์–ด์ง„๋‹ค๋ฉด JWS์™€ ํ•จ๊ป˜ JWE๋กœ ํด๋ ˆ์ž„ ํŽ˜์ด๋กœ๋“œ๊ฐ€ ์•”ํ˜ธํ™”๋œ JWT ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•ด๋ณผ ์ˆ˜ ์žˆ๋Š” ๊ฒƒ๋„ ์•Œ์•„๋ด์•ผํ•  ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. ๋ณธ ๊ธ€์—์„œ ์‚ฌ์šฉํ–ˆ๋˜ jjwt ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์—์„œ๋Š” JWE๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ Nimbus JOSE + JWT๋กœ ๋Œ€์ฒดํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์ฐธ๊ณ