jwt를 추가하려면, 먼저
implementation 'org.springframework.boot:spring-boot-starter-security'
이걸 추가해야 한다. 그리고 메인 어플을
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SpringAuthApplication {
public static void main(String[] args) {
SpringApplication.run(SpringAuthApplication.class, args);
}
}
이것을 이렇게 바꾼다.
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
@SpringBootApplication(exclude = SecurityAutoConfiguration.class) // Spring Security 인증 기능 제외
public class SpringAuthApplication {
public static void main(String[] args) {
SpringApplication.run(SpringAuthApplication.class, args);
}
}
Bean 수동 등록하는 방법
@Component를 넣으면 된다.
그예로
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
쿠키와 세션
쿠키와 세션 모두 HTTP 에 상태 정보를 유지(Stateful)하기 위해 사용됩니다. 즉, 쿠키와 세션을 통해 서버에서는 클라이언트 별로 인증 및 인가를 할 수 있게 됩니다.
- 쿠키
- 클라이언트에 저장될 목적으로 생성한 작은 정보를 담은 파일 입니다.
-
- 크롬 브라우저 기준으로 '개발자도구' 를 열어 보세요.
- Application - Storage - Cookies 에 도메인 별로 저장되어 있는게 확인 됩니다.클라이언트인 웹 브라우저에 저장된 '쿠키' 를 확인해 보죠
- 구성요소
- Name (이름): 쿠키를 구별하는 데 사용되는 키 (중복될 수 없음)
- Value (값): 쿠키의 값
- Domain (도메인): 쿠키가 저장된 도메인
- Path (경로): 쿠키가 사용되는 경로
- Expires (만료기한): 쿠키의 만료기한 (만료기한 지나면 삭제됩니다.)
- 세션
- 서버에서 일정시간 동안 클라이언트 상태를 유지하기 위해 사용됩니다.
- 서버에서 클라이언트 별로 유일무이한 '세션 ID' 를 부여한 후 클라이언트 별 필요한 정보를 서버에 저장합니다.
- 서버에서 생성한 '세션 ID' 는 클라이언트의 쿠키값('세션 쿠키' 라고 부름)으로 저장되어 클라이언트 식별에 사용됩니다.
즉 쿠키는 웹, 세션은 서버에 저장
package com.teamsparta.springauth.auth;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
@RestController
@RequestMapping("/api")
public class AuthController {
public static final String AUTHORIZATION_HEADER = "Authorization";
@GetMapping("/create-cookie")
public String createCookie(HttpServletResponse res) {
addCookie("Robbie Auth", res);//공백을 일부러 넣어서 공백 대체법 알아보기
return "createCookie";
}
@GetMapping("/get-cookie")
public String getCookie(@CookieValue(AUTHORIZATION_HEADER) String value) {
System.out.println("value = " + value);
return "getCookie : " + value;
}
public static void addCookie(String cookieValue, HttpServletResponse res) {//Robbie Auth를 받아서 쿠키 생성.
try {
cookieValue = URLEncoder.encode(cookieValue, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
//cookieValue를 인코딩. URL 인코딩은 특수 문자, 공백 등을 안전하게 전송할 수 있는 형식으로 변환하는 과정.
// 여기서 "utf-8"은 인코딩에 사용할 문자셋 공백을 '+'로 변환. 그러나 쿠키에서는 '+'가 올바르게 해석되지 않으므로, 이를 '%20’으로 대체
//이 부분은 새로운 쿠키를 생성. 쿠키의 이름은 AUTHORIZATION_HEADER 상수에 지정된 값이며,
// 쿠키의 값은 cookieValue 매개변수에 지정된 값
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, cookieValue); // Name-Value
cookie.setPath("/");//쿠키의 경로
cookie.setMaxAge(30 * 60);//쿠키의 수명. 30분
// Response 객체에 Cookie 추가
res.addCookie(cookie);//cookie를 HTTP 응답에 추가. 이렇게 하면 클라이언트는 이 쿠키를 받아서 저장하고,
// 이후 요청에서 이 쿠키를 서버에 전송할 수 있다.
} catch (UnsupportedEncodingException e) {//주어진 문자셋이 지원 안하는거면 에러메시지
throw new RuntimeException(e.getMessage());
}
}
}
이것이 쿠키를 사용하는 방식으로, 그중
public static void addCookie(String cookieValue, HttpServletResponse res) {//Robbie Auth를 받아서 쿠키 생성.
try {
cookieValue = URLEncoder.encode(cookieValue, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
//cookieValue를 인코딩. URL 인코딩은 특수 문자, 공백 등을 안전하게 전송할 수 있는 형식으로 변환하는 과정.
// 여기서 "utf-8"은 인코딩에 사용할 문자셋 공백을 '+'로 변환. 그러나 쿠키에서는 '+'가 올바르게 해석되지 않으므로, 이를 '%20’으로 대체
//이 부분은 새로운 쿠키를 생성. 쿠키의 이름은 AUTHORIZATION_HEADER 상수에 지정된 값이며,
// 쿠키의 값은 cookieValue 매개변수에 지정된 값
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, cookieValue); // Name-Value
cookie.setPath("/");//쿠키의 경로
cookie.setMaxAge(30 * 60);//쿠키의 수명. 30분
// Response 객체에 Cookie 추가
res.addCookie(cookie);//cookie를 HTTP 응답에 추가. 이렇게 하면 클라이언트는 이 쿠키
를 받아서 저장하고,
// 이후 요청에서 이 쿠키를 서버에 전송할 수 있다.
} catch (UnsupportedEncodingException e) {//주어진 문자셋이 지원 안하는거면 에러메시지
throw new RuntimeException(e.getMessage());
}
}
이부분이 쿠키를 생성하는곳으로 일부러 쿠키를 Robbie Auth라고 공백을 넣어서 ookieValue = URLEncoder.encode(cookieValue, "utf-8").replaceAll("\\+", "%20");를 통해 공백을 안전하게 %20으로 변환해서 쿠키를 인식시킴.
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, cookieValue); // Name-Value
cookie.setPath("/");
cookie.setMaxAge(30 * 60);
이건 각각 쿠키를 생성하고 경로, 수명을 정해주며
res.addCookie(cookie);//cookie를 HTTP 응답에 추가. 이렇게 하면 클라이언트는 이 쿠키를 받아서 저장하고,
// 이후 요청에서 이 쿠키를 서버에 전송할 수 있다.
} catch (UnsupportedEncodingException e) {//주어진 문자셋이 지원 안하는거면 에러메시지
throw new RuntimeException(e.getMessage());
이 부분은 그렇게 생긴 쿠키를 저장해서 사용할수 있게 해준다
@GetMapping("/create-cookie")
public String createCookie(HttpServletResponse res) {
addCookie("Robbie Auth", res);//공백을 일부러 넣어서 공백 대체법 알아보기
return "createCookie";
}//쿠키 생성
@GetMapping("/get-cookie")
public String getCookie(@CookieValue(AUTHORIZATION_HEADER) String value) {
System.out.println("value = " + value);
return "getCookie : " + value;
}//쿠키 조회
이건 각각 쿠키생성, 조회다.
이제 http://localhost:8080/api/create-cookie를 들어가면
createCookie라고 나온다.
http://localhost:8080/api/get-cookie라고 하면
getCookie : Robbie Auth
세션
@GetMapping("/create-session")
public String createSession(HttpServletRequest req) {
// 세션이 존재할 경우 세션 반환, 없을 경우 새로운 세션을 생성한 후 반환
HttpSession session = req.getSession(true);
// 세션에 저장될 정보 Name - Value 를 추가합니다. 즉 Robbie Auth를 저장
session.setAttribute(AUTHORIZATION_HEADER, "Robbie Auth");
return "createSession";//그리고 createSession을 출력
}
@GetMapping("/get-session")
public String getSession(HttpServletRequest req) {
// 세션이 존재할 경우 세션 반환, 없을 경우 null 반환
HttpSession session = req.getSession(false);
String value = (String) session.getAttribute(AUTHORIZATION_HEADER); // 가져온 세션에 저장된 Value 를 Name 을 사용하여 가져옵니다.
System.out.println("value = " + value);
return "getSession : " + value;
}
이렇게 @GetMapping("/create-session")로 새로운 세션을 생성하고 @GetMapping("/get-session")이것으로 세션을 조회할수 있다.
http://localhost:8080/api/create-session 이러면
createSession
http://localhost:8080/api/get-session 이러면
getSession : Robbie Auth 이렇게 제대로 작동한다.
실행시 터미널에도 value = Robbie Auth라도 나온다.
JWT 사용 이유
이렇게 서버가 여러개라면?
- Session 마다 다른 Client 로그인 정보를 가지고 있을 수 있습니다.
- Session1: Client1, Client2, Client3
- Session2: Client4
- Session3: Client5, Client6
- 만약 Client 1의 로그인 정보를 가지고 있지 않은 Sever2 나 Server3 에 API 요청을 하게되면 문제가 발생하지 않을까?
- 해결방법
- Sticky Session: Client 마다 요청 Server 고정합니다.
- 세션 저장소 생성하여 모든 세션을 저장합니다.
- 해결방법
이걸 jwt로 해결 가능
로그인 정보를 Server 에 저장하지 않고, Client 에 로그인 정보를 JWT 로 암호화하여 저장 → JWT 통해 인증/인가를 하면
모든 서버에서 동일한 Secret Key를 소유하기에 아무 문제도 없음!
- JWT 장/단점
- 장점
- 동시 접속자가 많을 때 서버 측 부하 낮춤(서버가 여러개여도 상관없음)
- Client, Sever 가 다른 도메인을 사용할 때
- 예) 카카오 OAuth2 로그인 시 JWT Token 사용
- 단점
- 구현의 복잡도 증가
- JWT 에 담는 내용이 커질 수록 네트워크 비용 증가 (클라이언트 → 서버)
- 조기 생성된 JWT 를 일부만 만료시킬 방법이 없음
- Secret key 유출 시 JWT 조작 가능
- 장점
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
cookie.setPath("/");
// Response 객체에 Cookie 추가
res.addCookie(cookie);
https://jwt.io/ 로들어가보면
여기에서
Header
{
"alg": "HS256",
"typ": "JWT"
}
Payload
{
"sub": "1234567890",
"username": "카즈하",
"admin": true
}
Signature
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
이렇게 되어있는데 Payload에 실제 유저의 정보가 들어있고, HEADER와 VERIFY SIGNATURE부분은 암호화 관련된 정보 라고 기억하면 된다
이제 jwt를 만들어본다.
먼저,
// JWT
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
jwt.secret.key=7Iqk7YyM66W07YOA7L2U65Sp7YG065+9U3ByaW5n6rCV7J2Y7Yqc7YSw7LWc7JuQ67mI7J6F64uI64ukLg== 이건 application.properties에 추가한다.
그리고 이걸 추가한다.
package com.teamsparta.springauth.jwt;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Base64;
@Component
public class JwtUtil {
// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
public static final String BEARER_PREFIX = "Bearer ";// Bearer라고 하고 한칸 띄어야함
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey. application.properties에 넣었던 그것과 연결
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 로그 설정
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
@PostConstruct//이 메소드는 JwtUtil 객체가 생성된 후에 실행. 이 메소드는 비밀 키를 Base64 디코딩하여
// java.security.Key 객체를 생성하고, 이 객체를 key 필드에 저장
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
}
따라서 이 클래스는 JWT를 생성하고 검증하는 데 필요한 설정과 유틸리티를 제공한다.
다음으론 UserRoleEnum을 추가한다.
package com.teamsparta.springauth.entity;
public enum UserRoleEnum {
USER(Authority.USER), // 사용자 권한
ADMIN(Authority.ADMIN); // 관리자 권한
private final String authority;
UserRoleEnum(String authority) {
this.authority = authority;
}
public String getAuthority() {
return this.authority;
}
public static class Authority {
public static final String USER = "ROLE_USER";
public static final String ADMIN = "ROLE_ADMIN";
}
}
이걸로 유저, 관리자 두개를 만들수 있게 되면 다시 JwtUtil로 돌아와 맨 밑에
// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();//현재 날짜를 가져옴
return BEARER_PREFIX +
Jwts.builder()//새로운 JWT를 생성하기 위한 JwtBuilder 객체를 생성
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact(); //JWT를 문자열로 변환
}
이걸 추가해준다, 이것은 주어진 사용자 이름과 권한으로 JWT를 생성하고, 이 JWT를 문자열로 변환하여 반환해준다.
그 다음엔
public void addJwtToCookie(String token, HttpServletResponse res) {//
try {
token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value를 받아 새 쿠키 생성
cookie.setPath("/");
// Response 객체에 Cookie 추가
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
logger.error(e.getMessage());//에러 발생시 메시지
}
}
이것은 만들어진 쿠키를 저장해준다.
// JWT 토큰 substring
public String substringToken(String tokenValue) {// tokenValue가 null이 아니고, 공백이 아닌 텍스트를 포함하고 있는지 확인
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);//tokenValue에서 "Bearer " 접두사를 제거하고, 실제 토큰 값을 반환
// substring(7)은 문자열의 7번째 인덱스부터 끝까지의 부분 문자열을 반환
}
logger.error("Not Found Token");
throw new NullPointerException("Not Found Token");//토큰이 유효하지 않으면 에러
}
StringUtils.hasText를 사용하여 공백, null을 확인하고 startsWith을 사용하여 토큰의 시작값이 Bearer이 맞는지 확인.
맞다면 순수 JWT를 반환하기 위해 substring을 사용하여 Bearer을 제거
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);//주어진 JWT를 파싱하고 검증
return true;//JWT가 유효하면 true 아니면 에러메시지들
} catch (SecurityException | MalformedJwtException | SignatureException e) {
logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
logger.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
이건 `Jwts.parserBuilder()` 를 사용하여 JWT를 파싱하고 JWT가 위변조되지 않았는지 secretKey(key)값을 넣어 확인해준다.
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
이것은 토큰에서 정보를 가져와서
Jwts.parserBuilder() 와 secretKey를 사용하여 JWT의 Claims를 가져와 담겨 있는 사용자의 정보를 사용하는데, 클레임은 JWT에 포함된 정보를 나타내며, 예를 들어 사용자의 이름, 이메일, 권한 등을 포함하고 있다. 그래서 이걸로 사용자를 식별하고, 사용자의 권한을 확인할 수 있다.
최종적으론 이렇게 된다
package com.teamsparta.springauth.jwt;
import com.teamsparta.springauth.entity.UserRoleEnum;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
@Component
public class JwtUtil {
// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
public static final String BEARER_PREFIX = "Bearer ";// Bearer라고 하고 한칸 띄어야함
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey. application.properties에 넣었던 그것과 연결
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 로그 설정
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
@PostConstruct//이 메소드는 JwtUtil 객체가 생성된 후에 실행. 이 메소드는 비밀 키를 Base64 디코딩하여
// java.security.Key 객체를 생성하고, 이 객체를 key 필드에 저장
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();//현재 날짜를 가져옴
return BEARER_PREFIX +
Jwts.builder()//새로운 JWT를 생성하기 위한 JwtBuilder 객체를 생성
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();// JWT를 문자열로 변환
}
// JWT Cookie 에 저장
public void addJwtToCookie(String token, HttpServletResponse res) {//
try {
token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value를 받아 새 쿠키 생성
cookie.setPath("/");
// Response 객체에 Cookie 추가
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
logger.error(e.getMessage());//에러 발생시 메시지
}
}
// JWT 토큰 substring
public String substringToken(String tokenValue) {// tokenValue가 null이 아니고, 공백이 아닌 텍스트를 포함하고 있는지 확인
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);//tokenValue에서 "Bearer " 접두사를 제거하고, 실제 토큰 값을 반환
// substring(7)은 문자열의 7번째 인덱스부터 끝까지의 부분 문자열을 반환
}
logger.error("Not Found Token");
throw new NullPointerException("Not Found Token");//토큰이 유효하지 않으면 에러
}//- StringUtils.hasText를 사용하여 공백, null을 확인하고 startsWith을 사용하여 토큰의 시작값이 Bearer이 맞는지 확인
//맞다면 순수 JWT를 반환하기 위해 substring을 사용하여 Bearer을 잘라냅니다.
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);//주어진 JWT를 파싱하고 검증
return true;//JWT가 유효하면 true 아니면 에러메시지들
} catch (SecurityException | MalformedJwtException | SignatureException e) {
logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
logger.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}