Spring Security를 이용하여 Application에서 사전에 구축한REST API에Authentication적용
。Application에Authentication을 구현한 이후에는JWT를 사용하여 인증을 수행한 후REST API를 사용.
Spring Securitydependency 정의 Spring Security 적용
。Maven의 경우pom.xml에 정의 후 reload.<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
- 개발용 자격증명의 Username과 Password 변경
。application.properties에 다음 구문을 추가.spring.security.user.name=user123 spring.security.user.password=pw456。해당 구문 추가 후 로그인 시 변경된 개발용 자격증명에 로그인이 가능.
SecurityFilterChain의 전반적인 Configuration 설정. SecurityFilterChain
。@Configuration를 선언한 Class에서HttpSecurityinstance를 매개변수로하여SecurityFilterChaintype의Spring Bean instance를 생성 및 return하는@Bean method를 구현.
▶HttpSecurityinstance에관련 Configuration을 설정 후HttpSecurity객체.build()로SecurityFilterChaininstance를 생성import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; @Configuration public class pgSecurityConfiguration { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // 모든 HTTP Request 중 인증된 HTTP Request에 대해 승인처리 설정 return http.authorizeHttpRequests( auth->auth.anyRequest().authenticated() ) // 인증되지않은 HTTP Request에 대해 Authorization Header를 요구. .httpBasic(Customizer.withDefaults()) // Session을 stateless 상태 .sessionManagement( session->session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) // CSRF 비활성화 .csrf(csrf->csrf.disable()) // SecurityFilterChain instance 생성 .build(); } }。
HttpSecurityinstance에 대해 일일이 설정하지않고, 연쇄적으로 설정을 구현하여 return.
。인증이 된 모든Http Request에 대해 승인
▶Spring Application에 전달되는 모든HTTP Request에 대해서 인증된HTTP Request에 대해서만 승인.
。인증되지않은HTTP Request에 대해 Authorization Header를 요구
▶Authorization Header요구 시Basic Authentication도출.
Basic Authentication: 웹브라우저의 인증 팝업으로Authorization Header의ID/PW입력 기능 제공.
。StatelessSession 설정.
Spring Security가Session을 사용하지 않도록Session Policy설정.
▶ 주로JWT Token을 활용하는REST APIAuthentication 구현 시 사용.
。CSRF protection비활성화 설정
Session이 존재하지 않는 경우(Stateless)에만 비활성화하며,Session이 존재하는 경우, 반드시CSRF protection을 활성화해야한다.
▶ Spring Security에서는 default로CSRF protection활성화로 설정되어있음.
- React에서
Spring Application의Spring Security로 보호된REST API를 호출하도록 설정하기
。Front-End Application에서API호출 시 전송하는HTTP Request에Spring에 접근할 수 있는Authorization Header를 포함하도록 설정.
。React Application에서 호출하는REST API는Spring Security에 의해 보호되어있음.
▶React Application에서 해당REST API를 호출할 수 있도록 설정
API를 호출하는Axiosinstance의property에Authorization header를 포함하기 Axios
。axios객체.get("URL" , { property })
▶{ property }는기본 URL,Header,timeout등을 지정가능하며, 이는 사전에axios.create({ property })를 통해 axios instance를 생성 활용 가능.const apiClient = axios.create({ baseURL : "http://localhost:8080", // axios instance에 기본 Header 설정 // axios instance를 이용해 API 호출시 해당 Header가 항상 포함. headers: { Authorization: "Basic dXNlcjEyMzpwdzQ1Ng==", } }) export const retrieveBean = (pathvariable) => apiClient.get(`/pg/hello-world-bean/${pathvariable}`);。
headers에 정의된{ }배열의Authorization은 인코딩된 형태의Spring Security에서 등록된 ID와 PW를 기입.
headers:HTTP Request의header객체.
。Accept,Authorization,Content-Type,Origin등의HTTP Header를 포함
。Authorization:JWT Token,HTTP Basic Auth를 추가 시 사용.
Preflight Request의OPTIONS HTTP Method에 의해 발생하는Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource오류 해결
。API호출 시검사-Network에서OPTIONS Request Method의Preflight Request가 우선적으로 전송되는것을 확인 할 수 있으며, 이후 실제HTTP Request가 전송되는것을 확인 가능.
▶ 해당Preflight Request의OPTIONS HTTP Method가access control check를 통과하지 못해 오류가 발생.
Spring Application에서SecurityFilterChain을 구현하는@Bean Method의HttpSecurityinstance 에서 모든 Client에게OPTIONS HTTP Request에 대한 Access를 허용하도록 설정http.authorizeHttpRequests( auth->auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated() )
- 모든
URL Pattern의OPTIONS HTTP Method방식의 인증된HTTP Request는 모두 허용.
▶ 구현 시Preflight Request의OPTIONS HTTP Method가 접근허용 설정되어 오류가 발생하지 않는다.
Preflight Request:
。브라우저가CORS Policy에 따라 Server에 실제HTTP Request를 전달하기 전 사전에 확인용도로OPTIONS Request Method를 사용하여 전달하는 Request. CORS
。Preflight Request는CORS Policy가 적용될때, 특정 조건을 만족 시 자동으로 발생.
Preflight Request발생 조건
。다음 조건을 만족하는 경우Preflight Request발생.
Cross-Origin:
。다른 URL의 출처(Origin)으로 요청 시 발생.
Simple Request에 해당하지 않는 경우.
。PUT,DELETE등의HTTP Method또는Authorization Header를 포함 시Preflight Request발생.
Simple Request:
。GET,HEAD,POST의HTTP Method만 사용.
。Content-Type이application/x-www-form-urlencoded,multipart/form-data,text/plain중 하나
。Custom Header (Authorization,X-Custom-Header)가 없는 경우
OPTIONS Request Method
。Server의 특정 Resource에서 허용하는HTTP Method를 물어보는 용도의 Request.
▶Client에서 해당resource URL에서 사용할 수 있는HTTP Method가 무엇인지 묻는 용도로 활용됨.
Access Control Check:
。Client가 Application의 특정Resource에 접근할 수 있는지를 확인하는 보안절차.
▶ 보안정책을 준수하고 권한이 없는 Client로부터의 Access를 차단하는 중요한 역할을 수행.
ReactApplication에서 ID와 PW 입력 시Spring에 Access가 가능한 유효한 자격증명인지 확인 후 인코딩된 형태의Token을 생성
。React에서ID,PW를 입력 후 Form 제출 시 생성된Token을Spring의Authentication TEST용도의 API로HTTP Request에 첨부하여 전송하여Spring에 Access가 가능한지 확인 후React에서 활용.
▶Authentication이 끝난Token을React Context에 보관하여REST API호출 시마다Context로부터Token을 가져오도록 설정.
。기존의Headers의Authorization하드코딩된Token을 로그인 시 생성되는Token으로 대체.
。해당 과정은HTTP Basic Authentication방식을 사용하여Base64로 생성한Base64코드를 Token으로 임시활용.
▶HTTP Basic Authentication는 보안성이 존재하지 않으므로, 사용 X
Spring에서Authentication TEST용도의 API 생성
。React의 로그인 시 생성된Token을 통해API를 호출하여HTTP Request의headers : { Authorization : "인코딩Token" }에 포함된Token이 유효한지 확인하는 용도// Authentication 용도의 API @GetMapping(path="/basicauth") public String basicAuthCheck(){ return "Success"; }。 생성된
Token을 포함한HTTP Request를 전송하여 API호출 시HTTP Response 200을 받을 경우,Token은 유효.
▶ 틀린ID,PW를 입력하여 생성한Token을 포함하여 전송 시HTTP Response 401가 반환되어Token이 유효하지 않은것을 확인 가능.
401: Resource에 유효한 자격증명이 없기 때문에HTTP Request가 적용되지 않았음.
Spring의Authentication TEST용도의API호출을 위한Axios method생성
。React Context인AuthContext.js에서 로그인 후 생성된Token을 전달받아서Authorization Header로 포함하여Spring의Authentication TEST용도의API를 호출.export const testApi = (token) => apiClient.get('/basicauth',{ headers : { Authorization : token, } })。
token을Axios HTTP Method매개변수의 property로Authentication Header를 설정
- 기존 하드코딩 로그인 방식 대신
API를 활용해Token목적의HTTP Basic Authentication를 통한Base64코드를 생성하는 방식의Authentication기능을 구현하여 로그인하는 함수 작성하기
。React Context인AuthContext.js에서 로그인 후username과password를 전달받아서Base64코드를 생성 후Authentication Header로서HTTP Request에 포함하여Spring의Authentication TEST용도의 API로 전송하여HTTP Response 200을 받을 경우 해당Token이 유효하다고 간주하여React Context에 저장.
。현재 과정에서ID:PW를Base64로 인코딩하여 앞에Basic을 명시한Base64코드(Basic Base64코드)를 생성하여Authorization Header에 포함하는Basic Authentication사용.
▶ 차후JWT Token을 활용.
。Axios HTTP Method의 경우Promise를 return하며Promise는Callback Method가 전부 실행완료되기전에 비동기로 다음 구문을 실행하는 특징을 지님.
▶ 함수에async를 선언 후await을 사용하여Callback Method를 모두 실행 한 후 다음 구문을 전개하도록 설정. Javascript - async/await
。변수 = await Promise인 경우,promise가 resolve되었을 때 값이 변수에 저장됨
▶HTTP Response가 변수에 저장된다.// AuthContext.js const [token, setToken] = useState(null) // await을 사용할 수 있도록 함수에 async를 선언. async function logIn(username, password){ // Base64로 인코딩된 ID와 PW 생성 // Basic Base64코드 로 전달. const baCode = 'Basic ' + window.btoa(username + ':' + password) // try ~ catch 구문으로 오류발생 시 false를 반환하도록 설정. try{ // 비동기를 통해 다음 구문을 실행하기 전에 Callback Method를 모두 실행하도록 await 선언. // Axios HTTP Method를 수행할 경우 HTTP Resopnse를 return하여 response.status로 상태코드 조회 가능. const response = await testAuthenticateApi(baCode) if (response.status == 200){ setAuthState(true) setUsername(username) // 로그인이 된 경우 Context에 Token 저장 setToken(baCode) return true } else { // 로그인 실패 시 WET코드를 방지하기위해 구현된 Logoutfunction() 사용. Logoutfunction() return false } } catch{ // 에러 발생시 구현된 Logoutfunction() 사용. Logoutfunction() return false } } // Logout Logic 구현 // 로그인에 실패하거나 로그아웃하는 경우우 Context의 Token을 Null로 설정 function Logoutfunction(){ setAuthState(false); setToken(null) setUsername(null) } return( <AuthContext.Provider value={ {AuthState,logIn,Logoutfunction,username,token}}> {children} </AuthContext.Provider> )。입력한 ID와 PW를 통해
Base64로 생성한Token을Authentication Header로 포함하여API호출 후 return된HTTP Response의Status Code가200일때, 로그인이 되도록 구현.
。추가로try~catch구문을 설정하여 오류 발생 시false를 반환하도록 설정
。로그인에 성공한 경우, 해당 유효한Token을React Context의Token저장용도의Global State로서 저장하며 로그인에 실패한 경우Global State을Null로 설정하며Logout기능을 수행하는Logoutfunction()에도Token저장용도의Global State을Null로 설정.
▶Context에전역State로 저장된Token은<Context객체.Provider>을 통해 다른 Component로 전달하도록 설정.
▶ 로그인에 실패하는 경우,Context의 저장된Token이NULL로 설정되어검사- Network - Fetch/XHR의HTTP Request에서Authentication Header로 설정된Token이 노출되지 않는다.
- 해당
logIn함수를 호출하고 있는LoginComponent.jsx의logInStatus함수에도async / await을 선언async function logInStatus(){ if (await AuthContext.logIn(username,password)){ setShowSuccess(true) navigate(`/welcome/${username}`) } else { setShowSuccess(false) } }
。Spring Application에서 등록된 ID와 PW를 입력 시Base64로 인코딩된 유효한Base64코드(Basic Base64코드)이 생성된 후Axios HTTP Method에서Authentication Header로서 포함되어Spirng의Authentication TEST용도의API를 호출하여HTTP Response 200반환.
。검사-Network-Fetch/XHR에서HTTP Request의URL Parameter( =basicauth)의Headers를 확인할 경우 다음처럼Authorization Header에React에서 전송한Base64코드가 포함되어있음을 확인 가능.
▶Fetch/XHR(Fecth/XMLHttpRequest) :Front-End Application에서 전송한비동기 HTTP Request을 표시.
。 잘못된 ID와 PW를 입력 시 유효하지 않은Base64코드이 생성 및 전달되면서HTTP Response 401반환.
401: Resource에 유효한 자격증명이 없기 때문에HTTP Request가 적용되지 않았음.
- 최종적으로
Spring Application에서 등록한 ID와 PW를 입력하여Base64로 생성한 유효한Base64코드을 생성 후Authentication Header로 포함하여API호출 후 return된HTTP Response의Status Code가200일때 로그인에 성공하면서 다음Welcome Component를 반환.
Axios HTTP Method가 Return하는Promise에async/await적용
。Axios HTTP Method의 경우Promise를 return하며Promise는Callback Method가 전부 실행완료되기전에 비동기로 다음 구문을 실행하는 특징을 지님.const baCode = 'Basic ' + window.btoa(username + ':' + password) testAuthenticateApi(baCode) .then((response)=>{console.log("HTTP Response : "+response)}) // .catch((error)=>{console.log(error)}) .finally(()=>{console.log("실행완료")}) console.log("Next Code")
。Promise의 비동기(AJAX) 특징으로Axios HTTP Method보다 다음 구문인console.log("Next Code")를 먼저 실행된 후then-finally순으로 실행됨을 확인 가능.
▶ 함수에async를 선언 후await을 사용하여Callback Method를 모두 실행 한 후 다음 구문을 전개하도록 설정.// await을 사용할 수 있도록 함수에 async를 선언. async function logIn(username, password){ const baCode = 'Basic ' + window.btoa(username + ':' + password) // 비동기를 통해 다음 구문을 실행하기 전에 Callback Method를 모두 실행하도록 await 선언. await testAuthenticateApi(baCode) .then((response)=>{console.log("HTTP Response : "+response)}) .catch((error)=>{console.log(error)}) .finally(()=>{console.log("실행완료")}) console.log("Next Code") }
HTTP Basic Authentication
。HTTP에서 가장 간단한Authentication방식.
▶ID:PW를Base64로 인코딩하여 앞에Basic을 명시한Base64코드(Basic Base64코드)을HTTP Request의Authorization Header로 포함하여 Authentication을 수행.
。window.btoa(id + ":" + pw)로ID : PW를Base64로 인코딩한Base64코드를 생성한다.
。Base64인코딩만 사용하여 암호화가 되지않아서 보안에 매우 취약.
▶TLS/SSL을 사용하여 보안을 강화.
。session을 사용하지 않는stateless특징으로HTTP Request마다Authentication정보를 포함해야한다.const baCode = 'Basic ' + window.btoa(username + ':' + password) testAuthenticateApi(baCode) .then((response)=>{console.log(response)}) .catch((error)=>{console.log(error)}) .finally(()=>{console.log("실행완료")}) export const testAuthenticateApi = (token) => apiClient.get('/basicauth',{ headers : { Authorization : token, } })
base64
。Binary Data를Text format으로 변환하는Encoding방식
▶ 데이터(JSON,XML,Binary Data등 ) 전송에 특화.
。단순Encoding방식이므로, 보안성이 없어 암호화 목적으로는 사용하지 않음.
。양방향변환으로서Base64로Encoding한 데이터는 다시Decoding하면서 원본으로 복원 할 수 있다.
Base64활용
image,fileData의binary data를Text로Encoding후 전송하여 다시binary data로Decoding
HTTP Basic Authentication:Basic Base64인코딩ID:PW
。JavaScript에서 생성 시window.btoa(문자열)사용. (Binary to ACSII)
const ba = 'Basic ' + window.btoa(ID + ':' + PW)
▶ 현재는JWT사용.
Hashing
。Data를 고정된 길이의 고유한Hash로 변환하는 과정으로 복원이 불가능한 단방향 변환.
▶ 주로 보안 및 무결성 검증에 사용
。단방향변환으로 원본 입력값으로 복원이 불가능
。입력값 크기에 관계없이 고정된 길이의Hash생성. (SHA-256: 64자 )
。충돌방지를 예방하기 위해 서로 다른 입력값으로Hasing시 동일한Hash를 갖지 않도록 설계.
。Bcrypt,SHA-256을 통한 암호화 알고리즘을 사용하여 보안성 존재.
Spring Security -BCrypt hashing활용
Hashing활용
- 비밀번호 저장
。비밀번호를Hashing하여Hash로 저장하여 로그인 시 입력된 비밀번호를Hashing하여 비교하는 비교대상으로 사용.
- 데이터 무결성 검증
。파일이 변조되었는지 확인하는 용도로 사용
- 디지털 서명 및 인증서
React Context에서Global State로 저장된 유효한Token을 활용하도록Refactoring
。각각의Axios HTTP Method의property에Authentication header에 중복적으로Token을 설정하는 방식이 아닌, 특정Axios instance가 API호출 수행 시HTTP Request를 Intercepting하여Authentication header에Token을 설정.
axios instance를 생성하는axios.create()함수를 정의하는JS파일( =apiClient.js)을 생성.//apiClient.js import axios from 'axios' export const apiClient = axios.create({ baseURL : "http://localhost:8080" })。 해당 함수를 통해 생성된
Axios instance를 통해 로그인 시Axios Interceptor를 활용해서 인증된Token을Authorization Header로 추가하는 기능을 구현.
。기존Axios HTTP Method에서 해당 함수를 import하여 생성된Axios instance를 사용하는걸로 refactoring을 수행.// TodoApiService.js import axios from 'axios' // Axios instance를 생성하여 반환하는는 함수를 import. import { apiClient } from './apiClient'; export const retrieveDataApi = (username) => apiClient.get(`/pg/jpa/users/${username}/posts`); export const deleteDataByIdApi = (id) => apiClient.delete(`/pg/jpa/posts/${id}`); export const retrievePostByPostIdApi = (username,id) => apiClient.get(`/pg/jpa/users/${username}/posts/${id}`) // todo가 HTTP Request Body로서 API 호출 수행. export const updatePostApi = (username, id, todo)=>apiClient.put(`/pg/jpa/users/${username}/posts/${id}` , todo) export const createPostApi = (username, todo)=>apiClient.post(`/pg/jpa/users/${username}/posts` , todo); export const testAuthenticateApi = (token) => axios.get('http://localhost:8080/basicauth',{ headers : { Authorization : token, } })
- 로그인하여 유효한
Token을 얻은 경우Axios interceptor를 활용하여 API호출 시Authorization Header를 추가하는 기능 구현하기
。apiClient.js의apiClient함수를 통해 생성된axios instace에Axios Request Interceptor를 설정 할 경우, 해당apiClient를 통해 생성된axios instance를 이용하여Axios HTTP Method를 통한 모든 API호출을 Intercepting하여Authorization Header를 설정.// AuthContext.js import { apiClient } from '../api/apiClient'; const response = await testAuthenticateApi(baToken) if (response.status === 200){ setAuthState(true) setUsername(username) setToken(baCode) // apiClient로 생성된 instance의 모든 API호출을 // Intercepting하여 Authentication Header를 설정. apiClient.interceptors.request.use( (config)=>{ console.log('intercepting and adding a token') config.headers.Authorization=baCode return config } ) return true } else { Logoutfunction() return false }▶
await을 통해Promise에서 성공적으로 반환된HTTP Response를const response로 인가.
。apiClient로 생성한Axios instance를 이용하여API호출시 해당Axios Request Interceptor가HTTP Request를 Server로 전송되기전에 Intercepting하여Configuration객체를 Customize하여HTTP Request의headers객체에{ Authorization : base64코드 }설정.// ApiService.js import { apiClient } from './apiClient'; import axios from 'axios'; export const retrieveBean = (pathvariable) => axios.get(`/pg/hello-world-bean/${pathvariable}`); export const retrieveBean = (pathvariable) => apiClient.get(`/pg/hello-world-bean/${pathvariable}`);
。Axios의axios instance로Axios HTTP Method를 통해 API호출 시Authentication header가 존재하지 않아Error가 발생.
。Axios Interceptor가 구현된apiClient의axios instance를 통해 호출 시 정상적으로 기능.
▶apiClient의axios instance를 통해 호출 시Axios HTTP Method의Property에 따로Authentication header를 정의하지 않고API호출을 수행하더라도,Axios Request Interceptor가 Intercepting하여HTTP Request의Authentication Header에 유효한Token이 포함되도록 설정
。Postgres_Post데이터 생성 시 오류가 발생할 경우,Postgres_User에 해당username의 데이터가 존재하는지 확인.
▶username를 참조하여 외래키로 연결되어있으므로Postgres_User에 로그인한 계정의 데이터가 없는경우Postgres_Post생성 시 부모테이블의 기본키가 없으므로 외래키 제약조건 오류 발생.
。apiClient를 이용해Authentication header없이API호출을 수행하는 다른React Component도 매번API호출을 수행 시Axios Interceptor가 정상적으로 작동됨을 관측 가능.
Axios Interceptors
。Axios의API호출로 인한HTTP RequestorHTTP Response가 처리되기전에 Intercepting하여 Customize할 수 있는 기능.
▶axios instance를 이용하여 Interceptor를 구현할 수 있다.
。해당 기능을 사용하는 경우header에Token추가,Error Handling,HTTP Response변환 등의 작업을 수행할 수 있다.
Request Interceptor:axios객체.interceptors.request
。특정Axios instance를 통해API 호출시HTTP Request가Server로 전송되기전에 Intercepting하여 Customize.
。axios객체.interceptors.request.use( callback )선언 시 callback의 매개변수로서 특정Axios HTTP Request를 Intercepting하여 전달되는Configuration 객체( =config)가 전달됨.
▶ 해당Configuration 객체를 수정 시 Intercepting한HTTP Request를 전송하기 전에Configuration을 변경할 수 있다.
Axios HTTP Request Configuration 객체구조
。Javascript에서config.headers.Authorization = token처럼HTTP Request를 Intercepting하여config을 통해 수정이 가능.{ "url": "/users", "method": "get", "headers": { "Accept": "application/json, text/plain, */*" }, "baseURL": "https://api.example.com", "timeout": 5000 }
url:HTTP Request를 수행할 API의URL Parameter
method:HTTP Request의HTTP Method
headers:HTTP Request의header객체.
。Accept,Authorization,Content-Type,Origin등의HTTP Header를 포함
。Authorization:JWT Token,HTTP Basic Auth를 추가 시 사용.
baseURL: 기본 URL
timeout:HTTP Request의 요청제한시간
params:GET HTTP Request의Query Parameter
data:POST,PUT에서 전달할 데이터.
Response Interceptor:axios객체.interceptors.response
。특정Axios instance를 통해API 호출후HTTP Response를 전달받을 경우Callback Method가 실행되기전에 Intercepting하여 Customize.
- Spring Application에서
JWT Token활용하도록 설정
JWT구현 시 필요한 설정
。JWT코드가 작성된 Class는@SpringBootApplication가 선언된Spring진입점 Class의Component Scan에 의해 식별되기위해 반드시 진입점 Class의Sub Package에 위치해야한다.
。JWT용도의SecurityFilterChain을 새로 구현하게되므로 기존에 구현된SecurityFilterChain의Configuration Class가 로딩되지 않도록 삭제하거나@Configuration을 삭제하여Component Scan에 의해 식별되지 않도록 설정
。Spring Securitydependency 정의 시 개발용 자격증명을 임의로 설정하기위해application.properties에서 작성한 다음 구문을 삭제.spring.security.user.name=user123 spring.security.user.password=pw456
JWT사용 시Spring에서 정의해야할 Dependency
spring-boot-starter-oauth2-resource-server
。Spring Boot Application을Spring Security와 통합하여OAuth 2.0 Resource Server로 설정하는 Library.
OAuth 2.0 Resource Server:Authenticated된 사용자만 접근할 수 있도록 보호된API를 제공하는 Server.
▶ 기존Application의REST API가 노출되어있으므로,Application을OAuth 2.0 Resource Server로 설정하여 보호된REST API를 제공하도록 설정됨.
。Dependency를 추가 시OAuth 2.0 Token( =JWT,Opaque Token등 )을 검증하여Authenticated된HTTP Request만 허용하도록 설정됨.<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency>
spring-boot-configuration-processor
。Spring Boot에서@ConfigurationProperties가 선언된 Class를 자동으로 문서화를 수행하고IDE에서 자동완성기능을 제공하는 Library.
▶META-INF/spring-configuration-metadata.json파일을 생성하여 문서를 자동화.<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> </dependency>
JWTCode
。총 5개의 Class로 구성.
JwtTokenServiceimport java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.stream.Collectors; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.stereotype.Service; @Service public class JwtTokenService { private final JwtEncoder jwtEncoder; public JwtTokenService(JwtEncoder jwtEncoder) { this.jwtEncoder = jwtEncoder; } public String generateToken(Authentication authentication) { var scope = authentication .getAuthorities() .stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(" ")); var claims = JwtClaimsSet.builder() .issuer("self") .issuedAt(Instant.now()) .expiresAt(Instant.now().plus(90, ChronoUnit.MINUTES)) .subject(authentication.getName()) .claim("scope", scope) .build(); return this.jwtEncoder .encode(JwtEncoderParameters.from(claims)) .getTokenValue(); } }
JwtTokenResponsepublic record JwtTokenResponse(String token) {}
JwtTokenRequestpublic record JwtTokenRequest(String username, String password) {}
JwtSecurityConfigimport java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.util.UUID; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; 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.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.servlet.handler.HandlerMappingIntrospector; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; @Configuration @EnableWebSecurity @EnableMethodSecurity public class JwtSecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, HandlerMappingIntrospector introspector) throws Exception { // h2-console is a servlet // https://github.com/spring-projects/spring-security/issues/12310 return httpSecurity .authorizeHttpRequests(auth -> auth .requestMatchers("/authenticate").permitAll() .requestMatchers(PathRequest.toH2Console()).permitAll() // h2-console is a servlet and NOT recommended for a production .requestMatchers(HttpMethod.OPTIONS,"/**") .permitAll() .anyRequest() .authenticated()) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session. sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .oauth2ResourceServer( OAuth2ResourceServerConfigurer::jwt) .httpBasic( Customizer.withDefaults()) .headers(header -> {header. frameOptions().sameOrigin();}) .build(); } @Bean public AuthenticationManager authenticationManager( UserDetailsService userDetailsService) { var authenticationProvider = new DaoAuthenticationProvider(); authenticationProvider.setUserDetailsService(userDetailsService); return new ProviderManager(authenticationProvider); } @Bean public UserDetailsService userDetailsService() { UserDetails user = User.withUsername("in28minutes") .password("{noop}dummy") .authorities("read") .roles("USER") .build(); return new InMemoryUserDetailsManager(user); } @Bean public JWKSource<SecurityContext> jwkSource() { JWKSet jwkSet = new JWKSet(rsaKey()); return (((jwkSelector, securityContext) -> jwkSelector.select(jwkSet))); } @Bean JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) { return new NimbusJwtEncoder(jwkSource); } @Bean JwtDecoder jwtDecoder() throws JOSEException { return NimbusJwtDecoder .withPublicKey(rsaKey().toRSAPublicKey()) .build(); } @Bean public RSAKey rsaKey() { KeyPair keyPair = keyPair(); return new RSAKey .Builder((RSAPublicKey) keyPair.getPublic()) .privateKey((RSAPrivateKey) keyPair.getPrivate()) .keyID(UUID.randomUUID().toString()) .build(); } @Bean public KeyPair keyPair() { try { var keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); return keyPairGenerator.generateKeyPair(); } catch (Exception e) { throw new IllegalStateException( "Unable to generate an RSA Key Pair", e); } } }
JwtAuthenticationControllerimport org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController public class JwtAuthenticationController { private final JwtTokenService tokenService; private final AuthenticationManager authenticationManager; public JwtAuthenticationController(JwtTokenService tokenService, AuthenticationManager authenticationManager) { this.tokenService = tokenService; this.authenticationManager = authenticationManager; } @PostMapping("/authenticate") public ResponseEntity<JwtTokenResponse> generateToken( @RequestBody JwtTokenRequest jwtTokenRequest) { var authenticationToken = new UsernamePasswordAuthenticationToken( jwtTokenRequest.username(), jwtTokenRequest.password()); var authentication = authenticationManager.authenticate(authenticationToken); var token = tokenService.generateToken(authentication); return ResponseEntity.ok(new JwtTokenResponse(token)); } }。해당
JWTJava 파일들을 Spring 진입점 Class의Sub package에 위치 지정 후JWT Token을 생성하기 위해Talend API로http://localhost:8080/authenticate로 다음 구문을HTTP Request Body로 포함하여POST HTTP Request를 전송.{ "username" : "in28minutes", "password" : "dummy" }
。HTTP Request를 전송 시Body에 입력된username과password를 토대로JWT Token이 생성되어HTTP Response Body로 반환.
▶React의Axios Http Method를 통해username과password를 전달하여 해당API호출 시HTTPResponse.data.token으로 참조 가능.
。생성된JWT Token 코드를 jwt.io에 입력 시 다음처럼header,payload,signature의 3개 구조로 사용자가 확인하기 용이하도록 표현.
。Application의REST API는OAuth 2.0 Resource Server로서 보호되므로, 생성된JWT Token을HTTP Request의Authorization Header를 설정하여REST API를 호출해야한다.
▶{ Authorization : "Bearer JWT토큰" }으로 설정.
。Authorization Header를 설정하지않고REST API호출 시OAuth 2.0 Resource Server에 의해API가 보호되어HTTP Response 401이 발생하지만,Authorization Header에Bearer JWT토큰을 설정 후REST API호출 시 정상적으로 Resource를 반환.
- React Application에서
JWT Token활용하여 Spring의REST API에 접근하도록 설정
。React에서JWT Token을 활용하여Auhtentication을 수행하고Spring의OAuth 2.0 Resource Server로 보호된REST API에 접근하는 상호작용을 정의.
- Spring의
JWT Token을 생성하는API로ID와PW를 포함한POST HTTP Request를 전달하여JWT Token을 가져오는Axios HTTP Method구현.
。Spring의"http://localhost:8080/authenticate"로 Mapping된 API에Talend API Tester로POST HTTP Request에JSON format으로ID와PW를 포함하여 전송 시JWT Token이 생성되어 반환된 것과 동일한 원리.// Spring의 "localhost:8080/authenticate" 로 Mapping된 API로 // ID와 PW를 Object type으로 포함하여 호출하여 `JWT Token`을 가져온다. export const JWTAuthenticationApi = (username,password) => apiClient.post('/authenticate', { username , password } )
AuthContext.js에서 해당JWT Token을 반환하는Axios HTTP Method를 이용하는 로그인 기능 구현
。기존HTTP Basic Authentication으로 구현된 로그인기능의logIn()함수를Refactoring.
。Axios HTTP Method를 통해JWT토큰코드를 생성하여HTTP Response로 가져올 경우,response.data.token으로 참조 가능.
▶await을 통해Promise에서 성공적으로 반환된HTTP Response를const response로 인가.
▶"Bearer " + response.data.token으로JWT Token변수설정.
▶JWT Token변수는React Context에Global State로서 저장.
。axios request interceptor를 구현하여 해당Axios instance로 API호출 시HTTP Request가 Server로 전달되기 전 Intercepting하여Authorization header를JWT Token변수로 설정.// AuthContext.js async function logIn(username , password){ try { // Axios HTTP method에 ID와 PW를 전달하여 JWT Token을 HTTP Response로 가져온다. const response = await JWTAuthenticationApi(username,password) if (response.status == 200){ // "Bearer JWT토큰코드" JWT Token변수 설정. const jwtToken = 'Bearer ' + response.data.token; setAuthState(true) setToken(jwtToken) setUsername(username) // axios instance를 통해 API 호출 시 Intercepting하여 Authentication Header를 JWT Token으로 설정. apiClient.interceptors.request.use((config)=>{ console.log('intercepting and adding a token') config.headers.Authorization=jwtToken return config }) return true } else { Logoutfunction() return false } } catch { Logoutfunction() return false } }
。JWT Token을 사용하도록 다음 기능을 구현한 후 로그인 시Axios Interceptor를 통해apiClient로 모든 API호출을 Interceping하여Authorization Header에JWT Token을 설정하여OAuth 2.0 Resource Server에 의해 보호된 모든Spring의REST API에 접근 가능.
▶Fetch/XHR의HTTP Request를 확인 시 로그인 시 입력한ID,PW를payload로서 확인 및Authorization header로서 포함된JWT Token을 확인 가능.
Fetch/XHR(Fecth / XMLHttpRequest) :Front-End Application에서 전송한비동기 HTTP Request을 표시.