인증과 인가
인증(Authentication)
- 인증 : 해당 유저가 실제 유저인지 인증하는 개념
- 실제 그 유저가 맞는지를 확인하는 절차 ex) 스마트폰 지문인식, 사이트 로그인 등
인가(Authorization)
- 인가 : 해당 유저가 특정 리소스에 접근이 가능한지 허가를 확인하는 개념
- ex) 관리자 페이지-관리자 권한, 회원/비회원 여부에 따라 다른 권한을 받음
“웹 애플리케이션 인증”
- 일반적으로 서버-클라이언트 구조로 되어있고, 실제로 이 두가지 요소는 아주 멀리 떨어져 있다.
- 그리고 Http 라는 프로토콜을 이용하여 통신하는데, 그 통신은 **비연결성(Connectionless) 무상태(Stateless)**로 이루어짐
비연결성(Connectionless)
서버와 클라이언트가 연결되어 있지 않다
채팅이나 게임 같은 것들을 하지 않는 이상 리소스를 절약하기 위해서 서버와 클라이언트는 실제로 연결되어 있지 않다
서버와 클라이언트가 실제로 계속 연결되어있다면 클라이언트는 그렇다고 쳐도, 서버의 비용이 기하급수적으로 늘어나기 때문에 서버는 하나의 요청에 하나의 응답을 내버리고 연결을 끊어버리고 있다
무상태(Stateless)
서버가 클라이언트의 상태를 저장하지 않는다
기존의 상태를 저장하는 것도 서버의 비용과 부담을 증가시킴
→ 기존의 상태가 없다고 가정하는 프로토콜을 이용해 구현되어 있다.
서버는 클라이언트가 직전에, 혹은 그 전에 어떠한 요청을 보냈는지 알지 못한다.
🧐그런데 우리가 인터넷을 사용할때는 왜 정보들이 잘 있는것처럼 연속성있게 사용할 수 있었을까?
/News/Sports/9 와 같이 url을 계층적으로 설계하고, 다음 요청에 대한 api url을 이전 계층에만 둔다면 연속적으로 사용하고 있다는 느낌을 줄 수 있음
🧐그렇다면 인증과 같이, 해당 유저가 인증을 통과했다는 사실은 상태값이 아닌가요?
어떻게 비연결성, 무상태 프로토콜에서 “유저가 인증되었다”라는 정보를 유지시켜야 한다는 과제를 어떻게 해결했는지 이제부터 설명한다고 함
인증의 방식
일반적으로 웹 애플리케이션은 아래 두 가지 방법을 통해서 인증을 처리함
- 쿠키-세션 방식:
서버가 ‘특정 유저가 로그인 되었다’는 상태를 저장하는 방식 인증과 관련된 아주 약간의 정보만 서버가 가지고 있게 되고 유저의 이전 상태의 전부는 아니더라도 인증과 관련된 최소한의 정보는 저장해서 로그인을 유지시킴
쿠키- 세션 방식의 인증
사용자 → (사용자 로그인) → 서버 ↔ (사용자 확인) ↔ 회원 DB
서버 → (회원정보세션 생성) → 세션 저장소
서버 ← (Session ID 발급) ← 세션 저장소
사용자 → (데이터 요청+쿠키) → 서버 → (쿠키 검증) → 세션 저장소
사용자 ← (응답+요청 데이터) ← 서버 ← 유저 정보(세션) 획득 ← 세션 저장소
- 사용자가 로그인 요청을 보냄
- 서버는 DB의 유저 테이블에서 아이디 비밀번호를 대조함
- 실제 유저테이블의 정보와 일치한다면 인증을 통과한 것으로 보고 “세션 저장소”에 해당 유저가 로그인 되었다는 정보를 넣음
- 세션 저장소에서는 유저의 정보와는 관련 없는 난수인 session-id를 발급함
- 서버는 로그인 요청의 응답으로 session-id를 보냄
- 클라이언트는 그 session-id를 쿠키라는 저장소에 보관하고 앞으로의 요청마다 세션아이디를 같이 보냄. (주로 HTTP header에 담아서 보냄)
- 클라이언트의 요청에서 쿠키를 발견했다면 서버는 세션 저장소에서 쿠키를 검증함
- 만약 유저정보를 받아왔다면 이 사용자는 로그인이 되어있는 사용자라는 뜻
- 이후에는 로그인 된 유저에 따른 응답을 내어줌
JWT 기반 인증
세션 저장소가 따로 없음
JWT(JSON Web Token)란 인증에 필요한 정보들을 암호화시킨 토큰을 의미함
JWT 기반 인증은 쿠키/세션 방식과 유사하게 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별함
쿠키- 세션 방식보다 훨씬 효율적일 수 있음
사용자 → (사용자 로그인) → 서버
서버 ↔ (사용자 확인) ↔ 회원 DB
서버 → (Access Token(JWT) 발급) → 서버
사용자 → (데이터 요청+JWT) → 서버
서버 → (Access Token 검증) → 서버
서버 → (응답+요청 데이터)→ 사용자
- 사용자가 로그인 요청을 보냄
- 서버는 DB의 유저 테이블을 뒤져서 아이디 비밀번호를 대조함
- 실제 유저테이블의 정보와 일치한다면 인증을 통과한 것으로 보고 유저의 정보를 JWT로 암호화 해서 내보냄
- 서버는 로그인 요청의 응답으로 jwt 토큰을 발급함
- 클라이언트는 그 토큰을 저장소에 보관하고 앞으로의 요청마다 토큰을 같이 보냄
- 클라이언트의 요청에서 토큰을 발견했다면 서버는 토큰을 검증함
- 이후에는 로그인 된 유저에 따른 응답을 내어줌
쿠키와 세션이란 무엇일까
쿠키와 세션 모두 HTTP 에 상태 정보를 유지(Stateful)하기 위해 사용됨
쿠키와 세션을 통해 서버에서는 클라이언트 별로 인증 및 인가를 할 수 있게 됨
- Cookie
- 클라이언트에 저장될 목적으로 생성한 작은 정보를 담은 파일
ex) 클라이언트인 웹 브라우저에 저장된 '쿠키' 를 확인
'개발자도구' → Application - Storage - Cookies 에 도메인 별로 저장되어 있음
Cookies : Cookie 저장소
Name Value Domain Path Expires/Max-Age Size HttpOnly Secure SameSite PartitionKey Priority
_ga_ZWG1NSHWD8 | GS1.1.1700187278.1.1.1700187379.0.0.0 | .spotify.com | / | 2024-12-21T02:16:19.793Z | 51 | Medium | ||||
sp_adid | abaad8ab-ccce-4b53-8d83-aeb2bf91cbf8 | .spotify.com | / | 2024-11-16T02:14:39.204Z | 43 | ✓ | None | Medium | ||
- 구성요소
- Name (이름): 쿠키를 구별하는 데 사용되는 키 (unique)
- Value (값): 쿠키의 값
- Domain (도메인): 쿠키가 저장된 도메인
- Path (경로): 쿠키가 사용되는 경로
- Expires (만료기한): 쿠키의 만료기한 (만료기한 지나면 삭제됨)
- Session
- 서버에서 일정시간 동안 클라이언트 상태를 유지하기 위해 사용됨
- 서버에서 클라이언트 별로 유일무이한 '세션 ID' 를 부여한 후 클라이언트 별 필요한 정보를 서버에 저장함
- 서버에서 생성한 '세션 ID' 는 클라이언트의 쿠키값('세션 쿠키' 라고 부름)으로 저장되어 클라이언트 식별에 사용됨
- 세션 동작 방식
HTTP 클라이언트 : 웹 브라우저
HTTP 서버 : 웹 서버
<1번 요청>
클라이언트 → Get <http://~~.kr> → 서버
클라이언트 ← Set-Cookie; ID=12A345 ← 서버
<2번 요청>
클라이언트 → Get <http://~~.kr> , Cookie : ID = 12A345 → 서버
클라이언트 ← ← 서버
서버는 세션ID 를 사용하여 세션을 유지합니다.
- 클라이언트가 서버에 1번 요청
- 서버가 세션ID 를 생성하고, 쿠키에 담아 응답 헤더에 전달
- 세션 ID 형태: "SESSIONID = 12A345"
- 클라이언트가 쿠키에 세션ID를 저장 ('세션쿠키')
- 클라이언트가 서버에 2번 요청
- 쿠키값 (세션 ID) 포함하여 요청
- 서버가 세션ID 를 확인하고, 1번 요청과 같은 클라이언트임을 인지
쿠키 세션
설명 | 클라이언트에 저장될 목적으로 생성한 작은 정보를 담은 파일 | 서버에서 일정시간 동안 클라이언트 상태를 유지하기 위해 사용 |
저장 위치 | 클라이언트 (웹 브라우져) | 웹 서버 |
사용 예 | 사이트 팝업의 "오늘 다시보지 않기" 정보 저장 | 로그인 정보 저장 |
만료 시점 | 쿠키 저장 시 만료일시 설정 가능 | |
(브라우져 종료시도 유지 가능) | 다음 조건 중 하나가 만족될 경우 만료됨 |
- 브라우저 종료 시까지
- 클라이언트 로그아웃 시까지
- 서버에 설정한 유지기간까지 해당 클라이언트의 재요청이 없는 경우 | | 용량 제한 | 브라우저 별로 다름 (크롬 기준)
- 하나의 도메인 당 180개
- 하나의 쿠키 당 4KB(=4096byte) | 개수 제한 없음 (단, 세션 저장소 크기 이상 저장 불가능) | | 보안 | 취약 (클라이언트에서 쿠키 정보를 쉽게 변경, 삭제 및 가로채기 당할 수 있음) | 비교적 안전 (서버에 저장되기 때문에 상대적으로 안전) |
실습!
main → java → com → auth 패키지 생성 → AuthController 클래스 생성
package com.sparta.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) {
try {
cookieValue = URLEncoder.encode(cookieValue, "utf-8").replaceAll("\\\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, cookieValue); // Name-Value
cookie.setPath("/");
cookie.setMaxAge(30 * 60);
// Response 객체에 Cookie 추가
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e.getMessage());
}
}
}
@GetMapping("/get-cookie")
public String getCookie(@CookieValue(AUTHORIZATION_HEADER) String value) {
System.out.println("value = " + value);
return "getCookie : " + value;
}
- public static final String *AUTHORIZATION_HEADER* = "Authorization";
- Authorization은 상수로 넣어주고 변수는 value라고 해서 받음
- 변수에 해당하는 값이 들어갈거임 우리가 예상할 수 있는 값은 바로 "Robbie Auth"
@GetMapping("/create-cookie")
public String createCookie(HttpServletResponse res) {
addCookie("Robbie Auth", res);
return "createCookie";
}
- 쿠키 생성
public static void addCookie(String cookieValue, HttpServletResponse res) {
try {
cookieValue = URLEncoder.encode(cookieValue, "utf-8").replaceAll("\\\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, cookieValue); // Name-Value
cookie.setPath("/");
cookie.setMaxAge(30 * 60);
// Response 객체에 Cookie 추가
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e.getMessage());
}
}
- new Cookie(AUTHORIZATION_HEADER, cookieValue);
- Cookie에 저장될 Name과 Value를 생성자로 받는 Cookie 객체를 생성합니다.
- setPath("/"), setMaxAge(30 * 60)
- Path와 만료시간을 지정합니다.
- HttpServletResponse 객체에 생성한 Cookie 객체를 추가하여 브라우저로 반환합니다.
- 이렇게 반환된 Cookie는 브라우저의 Cookie 저장소에 저장됩니다.
- Cookie 생성은 범용적으로 사용될 수 있기 때문에 static 메서드로 선언합니다.
- 쿠키 읽기
- @GetMapping("/get-cookie") public String getCookie(@CookieValue(AUTHORIZATION_HEADER) String value) { System.out.println("value = " + value); return "getCookie : " + value; }
@CookieValue("Cookie의 Name")
- Cookie의 Name 정보를 전달해주면 해당 정보를 토대로 Cookie의 Value를 가져옵니다.
Spring 서버 실행 → localhost:8080/api/create-cookie → 개발자 도구 → Cookies
Name Value Domain Path
Authorization | Robbie%20Auth | localhost | / |
아래에 Show URL-decoded 체크하면 원본 버전 볼 수 있음
Robbie Auth
세션 다루기
- Servlet에서는 유일무이한 '세션 ID'를 간편하게 만들수 있는 HttpSession을 제공
- HttpSession 생성 → AuthController에 다음 메서드들 추가
createSession
@GetMapping("/create-session")
public String createSession(HttpServletRequest req) {
// 세션이 존재할 경우 세션 반환, 없을 경우 새로운 세션을 생성한 후 반환
HttpSession session = req.getSession(true);
// 세션에 저장될 정보 Name - Value 를 추가합니다.
session.setAttribute(AUTHORIZATION_HEADER, "Robbie Auth");
return "createSession";
}
- HttpServletRequest를 사용하여 세션을 생성 및 반환할 수 있다.
- req.getSession(true)
- 세션이 존재할 경우 세션을 반환하고 없을 경우 새로운 세션을 생성한다.
- 세션에 저장할 정보를 Name-Value 형식으로 추가한다.
- 반환된 세션은 브라우저 Cookie 저장소에 ‘JSESSIONID’라는 Name으로 Value에 저장된다.
- HttpSession 읽기
getSession
@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;
}
- req.getSession(false)
- 세션이 존재할 경우 세션을 반환하고 없을 경우 null을 반환합니다.
- session.getAttribute(”세션에 저장된 정보 Name”)
- Name을 사용하여 세션에 저장된 Value를 가져온다
JWT란 무엇일까?
JWT(Json Web Token)
JSON 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token
토큰의 한 종류 일반적으로 쿠키 저장소를 사용하여 JWT를 저장
JWT(Json Web Token)
- 서버가 1대인 경우
Session1 이 모든 Client의 로그인 정보를 소유
- 서버가 2대 이상인 경우
서버의 대용량 트래픽 처리를 위해 서버 2대 이상 운영이 필요할 수 있다
Session 마다 다른 Client 로그인 정보를 가지고 있을 수 있다
Session1: Client1, Client2, Client3
Session2: Client4
Session3: Client5, Client6
(Q) Client 1의 로그인 정보를 가지고 있지 않은 Sever2 나 Server3 에 API 요청을 하게되면?
(A) 해결방법
(1) Sticky Session: Client 마다 요청 Server를 고정시킴
(2) 세션 저장소 생성하여 모든 세션을 저장함
세션 저장소 생성
Session storage 가 모든 Client 의 로그인 정보를 소유, 모든 서버에서 모든 Client의 API 요청을 처리할 수 있다.
JWT 사용
로그인 정보를 Server 에 저장하지 않고, Client 에 로그인 정보를 JWT 로 암호화하여 저장 → JWT 통해 인증/인가
모든 서버에서 동일한 Secret Key를 소유함
Secret Key 통한 암호화 / 위조 검증 (복호화 시)
JWT 장점
동시 접속자가 많을 때 서버 측 부하 낮춤
Client, Sever 가 다른 도메인을 사용할 때도 가능(카카오 OAuth2 로그인 시 JWT Token 사용)
JWT 단점
구현의 복잡도 증가
JWT 에 담는 내용이 커질 수록 네트워크 비용 증가 (클라이언트 → 서버)
생성된 JWT 를 일부만 만료시킬 방법이 없음
Secret key 유출 시 JWT 조작 가능
Client 가 username, password 로 로그인 성공 시 JWT 사용 흐름
- 서버에서 "로그인 정보" → JWT 로 암호화 (Secret Key 사용)
- 서버에서 직접 쿠키를 생성해 JWT를 담아 Client 응답에 전달
JWT 전달방법은 개발자가 정함
- 응답 Header에 아래 형태로 JWT 전달
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
cookie.setPath("/");
// Response 객체에 Cookie 추가
res.addCookie(cookie);
3)브라우저 쿠키 저장소에 자동으로 JWT 저장됨
Client 에서 JWT 통해 인증시 JWT 사용 흐름
- 서버에서 API 요청 시마다 쿠키에 포함된 JWT를 찾아서 사용
- 쿠키를 찾는 코드
// HttpServletRequest 에서 Cookie Value : JWT 가져오기
public String getTokenFromRequest(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();
if(cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
try {
return URLDecoder.decode(cookie.getValue(), "UTF-8"); // Encode 되어 넘어간 Value 다시 Decode
} catch (UnsupportedEncodingException e) {
return null;
}
}
}
}
return null;
}
- 쿠키에 담긴 정보가 여러 개일 수 있기 때문에 그 중 이름이 JWT가 담긴 쿠키의 이름과 동일한지 확인하여 JWT를 가져옴
- Server
- Client 가 전달한 JWT 위조 여부 검증 (Secret Key 사용)
- JWT 유효기간이 지나지 않았는지 검증
- 검증 성공시, JWT 에서 사용자 정보를 가져와 확인
- ex) GET /api/products : JWT 보낸 사용자의 관심상품 목록 조회
JWT 구조
- JWT 는 누구나 평문으로 복호화 가능
- 하지만 Secret Key 가 없으면 JWT 수정 불가능
결론 : JWT 는 Read only 데이터
<https://jwt.io/>
- Header
- { "alg": "HS256", "typ": "JWT" }
- Payload
- { "sub": "1234567890", "username": "카즈하", "admin": true }
- Signature (Verify Signature)
- HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
- Payload에 실제 유저의 정보가 들어있음
- HEADER와 VERIFY SIGNATURE부분은 암호화 관련된 정보 양식
JWT 다루기
JWT dependency 추가하기
// 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'
main>resources>application.properties
jwt.secret.key=7Iqk7YyM66W07YOA7L2U65Sp7YG065+9U3ByaW5n6rCV7J2Y7Yqc7YSw7LWc7JuQ67mI7J6F64uI64ukLg==
그냥 문자열 그대로 추가를 한 게 아니라 더 안정성을 높이기 위해서
base64로 인코딩을 해두었다고 함
Util 클래스
특정 매개 변수(파라미터)에 대한 작업을 수행하는 메서드들이 존재하는 클래스 다른 객체에 의존하지 않고 하나의 모듈로서 동작하는 클래스 우리는 JWT 관련 기능들을 가진 JwtUtil이라는 클래스를 만들어 JWT 관련 기능을 수행시킬 예정입니다. <JWT 관련 기능>
- JWT 생성
- 생성된 JWT를 Cookie에 저장
- Cookie에 들어있던 JWT 토큰을 Substring
- JWT 검증
- JWT에서 사용자 정보 가져오기
- JWT를 Header에 달아서 내보내는 방법 → 2주차 강의 → 코드 수 단축 가능
- JWT를 담은 Cookie객체를 Response객체에 담는 방법 → 1주차 강의, 만료기한이나 옵션추가 가능
main>jwt 패키지 생성 > JwtUtil 클래스 생성
JWT 데이터
package com.sparta.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 ";
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 로그 설정
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
}
토큰 생성에 필요한 데이터
// 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 ";
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey 가져옴
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 로그 설정
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
- Base64로 Encode된 Secret Key를 properties에 작성해두고 @Value를 통해 가져옴
- JWT를 생성할 때 가져온 Secret Key로 암호화
- 이때 Encode된 Secret Key를 Decode 해서 사용
- Key : Decode된 Secret Key를 담는 객체
- @PostConstruct : 딱 한 번만 받아오면 되는 값을 사용 할 때마다 요청을 새로 호출하는 실수를 방지하기 위해 사용
- JwtUtil 클래스의 생성자 호출 이후에 실행되어 Key 필드에 값을 주입 해줌
- 암호화 알고리즘은 HS256 알고리즘을 사용
- Bearer 란 JWT 혹은 OAuth에 대한 토큰을 사용한다는 표시
- 로깅이란 애플리케이션이 동작하는 동안 프로젝트의 상태나 동작 정보를 시간순으로 기록하는 것을 의미
- Logback 로깅 프레임워크를 사용해서 로깅을 진행
entity 패키지 > UserRoleEnum 생성
package com.sparta.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";
}
}
사용자의 권한의 종류를 Enum을 사용해서 관리함
- JWT를 생성할 때 사용자의 정보로 해당 사용자의 권한을 넣어줄 때 사용함
- JWT 생성 (JwtUtil에)
// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
- JWT의 subject에 사용자의 식별값(ID)를 넣는다
- JWT에 사용자의 권한 정보를 넣는다.
- claim 을 이용해 key-value 형식으로 넣음, key 값을 통해 확인가능
- 토큰 만료시간을 넣음( ms 기준)
- issuedAt에 발급일을 넣음
- signWith에 secretKey 값을 담고있는 key와 암호화 알고리즘을 넣음
- key와 암호화 알고리즘을 사용하여 JWT를 암호화함
- JWT Cookie에 저장
// 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());
}
}
- 받아온 Cookie의 Value인 JWT 토큰 substring
// JWT 토큰 substring
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
logger.error("Not Found Token");
throw new NullPointerException("Not Found Token");
}
- StringUtils.hasText를 사용하여 공백, null을 확인
- startsWith을 사용하여 토큰의 시작값이 Bearer이 맞는지 확인함(토큰은 무조건 BEARER로 시작)
- 맞다면 순수 JWT를 반환하기 위해 substring을 사용하여 Bearer을 잘라냄
- 아까 붙여놓은 BEARER (공백포함)이 7자라 (0~6제외)7자리부터 반환
- .JWT 검증
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return 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)값을 넣어 확인
- JWT에서 사용자 정보 가져오기
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
- JWT의 구조 중 Payload 부분에는 토큰에 담긴 정보가 들어있음
- 여기에 담긴 정보의 한 ‘조각’ 을 클레임(claim) 이라고 부르고, 이는 key-value 의 한 쌍으로 이뤄져있습니다. 토큰에는 여러개의 클레임 들을 넣을 수 있음
- Jwts.parserBuilder() 와 secretKey를 사용하여 JWT의 Claims를 가져와 담겨 있는 사용자의 정보를 사용함
JwtUtil
package com.sparta.springauth.jwt;
import com.sparta.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 ";
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 로그 설정
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
@PostConstruct
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()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
// 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) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
logger.error("Not Found Token");
throw new NullPointerException("Not Found Token");
}
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return 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();
}
}
- AuthController에 다음 부분 추가
private final JwtUtil jwtUtil;
public AuthController(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
이게 싫으면 @RequiredArgsConstructor를 하라고 함
일관성 유지때문에 생성자 주입으로 계속 만드는 거라고 함
@GetMapping("/create-jwt")
public String createJwt(HttpServletResponse res) {
// Jwt 생성
String token = jwtUtil.createToken("Robbie", UserRoleEnum.USER);
// Jwt 쿠키 저장
jwtUtil.addJwtToCookie(token, res);
return "createJwt : " + token;
}
@GetMapping("/get-jwt")
public String getJwt(@CookieValue(JwtUtil.AUTHORIZATION_HEADER) String tokenValue) {
// JWT 토큰 substring
String token = jwtUtil.substringToken(tokenValue);
// 토큰 검증
if(!jwtUtil.validateToken(token)){
throw new IllegalArgumentException("Token Error");
}
// 토큰에서 사용자 정보 가져오기
Claims info = jwtUtil.getUserInfoFromToken(token);
// 사용자 username
String username = info.getSubject();
System.out.println("username = " + username);
// 사용자 권한
String authority = (String) info.get(JwtUtil.AUTHORIZATION_KEY);
System.out.println("authority = " + authority);
return "getJwt : " + username + ", " + authority;
}
build.gradle : JPA, MySQL 추가
or 생성시 MySQL Driver랑 JPA를 lombok, thymeleaf, web과 함께 추가
// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// MySQL
runtimeOnly 'com.mysql:mysql-connector-j'
main>resources>application.properties
spring.datasource.url=jdbc:mysql://localhost:3306/auth
spring.datasource.username=root
spring.datasource.password={비밀번호}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
🚩이거 빨간줄 뜸
⛳gradle에 mysql부분 지웠다 다시 쓰고 gradle 코끼리 새로고침했더니 해결됨
mysql에
create database auth;
하고
show databases;
로 확인!
IntelliJ에 연결
controller > HomeController
package com.sparta.springauth.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HomeController {
@GetMapping("/")
public String home(Model model) {
model.addAttribute("username", "username");
return "index";
}
}
메인페이지를 반환함
controller > UserController
package com.sparta.springauth.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/api")
public class UserController {
@GetMapping("/user/login-page")
public String loginPage() {
return "login";
}
@GetMapping("/user/signup")
public String signupPage() {
return "signup";
}
}
로그인/회원가입페이지를 반환함
@RestController말고 그냥 @Controller여서 templates에서 html페이지가 반환됨
src > main > resources > templates 에 html파일 3개 생성
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link rel="stylesheet" href="/css/style.css">
<script src="https://code.jquery.com/jquery-3.7.0.min.js" integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@3.0.5/dist/js.cookie.min.js"></script>
<script src="/js/basic.js"></script>
<title>나만의 셀렉샵</title>
</head>
<body>
<div class="header" style="position:relative;">
<div id="login-true" style="display: none">
<div id="header-title-login-user">
<span th:text="${username}"></span> 님의
</div>
<div id="header-title-select-shop">
Select Shop
</div>
<a id="login-text" href="javascript:logout()">
로그아웃
</a>
</div>
<div id="login-false" >
<div id="header-title-select-shop">
My Select Shop
</div>
<a id="sign-text" href="/api/user/signup">
회원가입
</a>
<a id="login-text" href="/api/user/login-page">
로그인
</a>
</div>
</div>
</body>
</html>
login.html
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link rel="stylesheet" type="text/css" href="/css/style.css">
<meta charset="UTF-8">
<title>로그인 페이지</title>
</head>
<body>
<div id="login-form">
<div id="login-title">Log into Select Shop</div>
<br>
<br>
<button id="login-id-btn" onclick="location.href='/api/user/signup'">
회원 가입하기
</button>
<form action="/api/user/login" method="post">
<div class="login-id-label">아이디</div>
<input type="text" name="username" class="login-input-box">
<div class="login-id-label">비밀번호</div>
<input type="password" name="password" class="login-input-box">
<button id="login-id-submit">로그인</button>
</form>
<div id="login-failed" style="display: none" class="alert alert-danger" role="alert">로그인에 실패하였습니다.</div>
</div>
</body>
<script>
const href = location.href;
const queryString = href.substring(href.indexOf("?")+1)
if (queryString === 'error') {
const errorDiv = document.getElementById('login-failed');
errorDiv.style.display = 'block';
}
</script>
</html>
const href = location.href;
const queryString = href.substring(href.indexOf("?")+1)
if (queryString === 'error') {
const errorDiv = document.getElementById('login-failed');
errorDiv.style.display = 'block';
}
signup.html
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link rel="stylesheet" type="text/css" href="/css/style.css">
<meta charset="UTF-8">
<title>회원가입 페이지</title>
<script>
function onclickAdmin() {
// Get the checkbox
var checkBox = document.getElementById("admin-check");
// Get the output text
var box = document.getElementById("admin-token");
// If the checkbox is checked, display the output text
if (checkBox.checked == true){
box.style.display = "block";
} else {
box.style.display = "none";
}
}
</script>
</head>
<body>
<div id="login-form">
<div id="login-title">Sign up Select Shop</div>
<form action="/api/user/signup" method="post">
<div class="login-id-label">Username</div>
<input type="text" name="username" placeholder="Username" class="login-input-box">
<div class="login-id-label">Password</div>
<input type="password" name="password" class="login-input-box">
<div class="login-id-label">E-mail</div>
<input type="text" name="email" placeholder="E-mail" class="login-input-box">
<div>
<input id="admin-check" type="checkbox" name="admin" onclick="onclickAdmin()" style="margin-top: 40px;">관리자
<input id="admin-token" type="password" name="adminToken" placeholder="관리자 암호" class="login-input-box" style="display:none">
</div>
<button id="login-id-submit">회원 가입</button>
</form>
</div>
</body>
</html>
src > main > resources > static > css폴더 > style.css
* {
font-family: 'Georgia', serif;
}
body {
margin: 0px;
}
.header {
height: 255px;
box-sizing: border-box;
background-color: #15aabf;
color: white;
text-align: center;
padding-top: 80px;
/*padding: 50px;*/
font-size: 45px;
font-weight: bold;
}
#header-title-login-user {
font-size: 36px;
letter-spacing: -1.08px;
}
#header-title-select-shop {
margin-top: 20px;
font-size: 45px;
letter-spacing: 1.1px;
}
#login-form {
width: 538px;
height: 710px;
margin: 70px auto 141px auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
/*gap: 96px;*/
padding: 56px 0 0;
border-radius: 10px;
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.15);
background-color: #ffffff;
}
#login-title {
width: 303px;
height: 32px;
/*margin: 56px auto auto auto;*/
flex-grow: 0;
font-family: SpoqaHanSansNeo;
font-size: 32px;
font-weight: bold;
font-stretch: normal;
font-style: normal;
line-height: 1;
letter-spacing: -0.96px;
text-align: left;
color: #212529;
}
#login-kakao-btn {
border-width: 0;
margin: 96px 0 8px;
width: 393px;
height: 62px;
flex-grow: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 10px;
/*margin: 0 0 8px;*/
padding: 11px 12px;
border-radius: 5px;
background-color: #ffd43b;
font-family: SpoqaHanSansNeo;
font-size: 20px;
font-weight: bold;
font-stretch: normal;
font-style: normal;
color: #414141;
}
#login-id-btn {
width: 393px;
height: 62px;
flex-grow: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 10px;
/*margin: 8px 0 0;*/
padding: 11px 12px;
border-radius: 5px;
border: solid 1px #212529;
background-color: #ffffff;
font-family: SpoqaHanSansNeo;
font-size: 20px;
font-weight: bold;
font-stretch: normal;
font-style: normal;
color: #414141;
}
.login-input-box {
border-width: 0;
width: 370px !important;
height: 52px;
margin: 14px 0 0;
border-radius: 5px;
background-color: #e9ecef;
}
.login-id-label {
/*width: 44.1px;*/
/*height: 16px;*/
width: 382px;
padding-left: 11px;
margin-top: 40px;
/*margin: 0 337.9px 14px 11px;*/
font-family: NotoSansCJKKR;
font-size: 16px;
font-weight: normal;
font-stretch: normal;
font-style: normal;
line-height: 1;
letter-spacing: -0.8px;
text-align: left;
color: #212529;
}
#login-id-submit {
border-width: 0;
width: 393px;
height: 62px;
flex-grow: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 10px;
margin: 40px 0 0;
padding: 11px 12px;
border-radius: 5px;
background-color: #15aabf;
font-family: SpoqaHanSansNeo;
font-size: 20px;
font-weight: bold;
font-stretch: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-align: center;
color: #ffffff;
}
#sign-text {
position:absolute;
top:48px;
right:110px;
font-size: 18px;
font-family: SpoqaHanSansNeo;
font-size: 18px;
font-weight: 500;
font-stretch: normal;
font-style: normal;
line-height: 1;
letter-spacing: 0.36px;
text-align: center;
color: #ffffff;
}
#login-text {
position:absolute;
top:48px;
right:50px;
font-size: 18px;
font-family: SpoqaHanSansNeo;
font-size: 18px;
font-weight: 500;
font-stretch: normal;
font-style: normal;
line-height: 1;
letter-spacing: 0.36px;
text-align: center;
color: #ffffff;
}
.alert-danger {
color: #721c24;
background-color: #f8d7da;
border-color: #f5c6cb;
}
.alert {
width: 300px;
margin-top: 22px;
padding: 1.75rem 1.25rem;
border: 1px solid transparent;
border-radius: .25rem;
}
src > main > resources > static > js폴더 > basic.js
let host = 'http://' + window.location.host;
$(document).ready(function () {
const auth = getToken();
if(auth === '') {
window.location.href = host + "/api/user/login-page";
} else {
$('#login-true').show();
$('#login-false').hide();
}
})
function logout() {
// 토큰 삭제
Cookies.remove('Authorization', { path: '/' });
window.location.href = host + "/api/user/login-page";
}
function getToken() {
let auth = Cookies.get('Authorization');
if(auth === undefined) {
return '';
}
return auth;
}
회원가입 설계
컬럼명 컬럼타입 중복허용 설명
id | Long | X | 테이블 ID(PK) |
username | String | X | 회원 ID |
password | String | O | 패스워드 |
String | X | 이메일 주소 | |
role | String | O | 역할 |
- 사용자: USER
- 관리자: ADMIN |
- 회원 DB에 매핑되는 @Entity 클래스 구현
entity > User
package com.sparta.springauth.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private UserRoleEnum role;
}
@Table(name = "users") 라고 생성하면 users라고 테이블이 만들어짐
- @Enumerated(value = EnumType.*STRING*)
- EnumType을 DB 컬럼에 저장할 때 사용하는 애너테이션
- EnumType.*STRING* 옵션을 사용하면 **Enum의 이름을 DB에 그대로 저장
- 이 값을 넣는다고 하면 :USER(Authority.USER) → USER DB에 이거 그대로 저장됨
- '관리자' 권한을 부여할 수 있는 관리자 페이지 구현
- 승인자에 의한 결재 과정 구현 → 관리자 권한 부여
- 앨리스가 여러분의 사이트에 회원가입을 하며 아이디, 패스워드를 입력함
- 아이디: alice
- 패스워드: nobodynobody
- 양방향 암호 알고리즘
- 암호화: 평문 → (암호화 알고리즘) → 암호문
- 복호화: 암호문 → (암호화 알고리즘) → 평문
- 단방향 암호 알고리즘
- 암호화: 평문 → (암호화 알고리즘) → 암호문
- 복호화: 불가 (
암호문 → (암호화 알고리즘) → 평문)
- 암호화 후 패스워드 저장이 필요
- 만약 해커에 의해 회원정보가 갈취당한다면 앨리스의 패스워드는 모두가 알게 됨
- 출처: KISA 개인정보 종류와 적용 가능 암호기술
- 회원 등록 시 '비밀번호'는 사용자가 입력한 문자 그대로 DB 에 등록하면 안 됨
- 보통 현업에서는
- "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC"
- '관리자 가입 토큰' 입력 필요
Password 확인절차
- 사용자가 로그인을 위해 "아이디, 패스워드 (평문)" 입력 → 서버에 로그인 요청
- 서버에서 패스워드 (평문) 을 암호화
- 평문 → (암호화 알고리즘) → 암호문
- DB 에 저장된 "아이디, 패스워드 (암호문)"와 일치 여부 확인
Password Matching
Spring Security라는 프레임워크에서 제공하는 비밀번호 암호화 기능을 사용해보자 일전에 Bean 수동등록 예제로 봤던 PasswordEncoder가 해당 Security에서 제공하는 비밀번호 암호화 메서드임 사용자가 입력한 비밀번호를 암호화되어 저장된 비밀번호와 비교하여 일치여부를 확인해주는 기능도 가지고 있어 많이 사용됨
// 사용예시
// 비밀번호 확인
if(!passwordEncoder.matches("사용자가 입력한 비밀번호", "저장된 비밀번호")) {
throw new IllegalAccessError("비밀번호가 일치하지 않습니다.");
}
- boolean matches(CharSequence rawPassword, String encodedPassword);
- rawPassword : 사용자가 입력한 비밀번호
- encodedPassword : 암호화되어 DB 에 저장된 비밀번호
07. 회원가입 API 구현
Name Method URL
회원가입 페이지 | GET | /api/user/signup | 회원가입 페이지 호출 |
회원가입 | POST | /api/user/signup | 회원가입 |
UserController에 다음 코드 추가
@PostMapping("/user/signup")
public String signup(@ModelAttribute SignupRequestDto requestDto {
}
SignupRequestDto 생성
package com.sparta.springauth.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class SignupRequestDto {
private String username;
private String password;
private String email;
private boolean admin = false;
private String adminToken = "";
}
UserService 생성
package com.sparta.springauth.service;
import com.sparta.springauth.dto.SignupRequestDto;
import com.sparta.springauth.entity.User;
import com.sparta.springauth.entity.UserRoleEnum;
import com.sparta.springauth.repository.UserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
// ADMIN_TOKEN
private final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";
public void signup(SignupRequestDto requestDto) {
String username = requestDto.getUsername();
String password = passwordEncoder.encode(requestDto.getPassword());
// 회원 중복 확인
Optional<User> checkUsername = userRepository.findByUsername(username);
if (checkUsername.isPresent()) {
throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
}
// email 중복확인
String email = requestDto.getEmail();
Optional<User> checkEmail = userRepository.findByEmail(email);
if (checkEmail.isPresent()) {
throw new IllegalArgumentException("중복된 Email 입니다.");
}
// 사용자 ROLE 확인
UserRoleEnum role = UserRoleEnum.USER;
if (requestDto.isAdmin()) {
if (!ADMIN_TOKEN.equals(requestDto.getAdminToken())) {
throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
}
role = UserRoleEnum.ADMIN;
}
// 사용자 등록
User user = new User(username, password, email, role);
userRepository.save(user);
}
}
repository>interface UserRepository extends JpaRepository생성
package com.sparta.springauth.repository;
import com.sparta.springauth.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User,Long> {
}
UserController에 다음 코드 추가, DI로 받아온다
private final UserService userService;
public UserController(UserService userService){
this.userService = userService;
}
UserController에 아까 만들었던 함수 채움
@PostMapping("/user/signup")
public String signup(@ModelAttribute SignupRequestDto requestDto {
userService.signup(requestDto);//이게 문제없이 수행됐으면 로그인 하라고 로그인 페이지 반환
return "redirect:/api/user/login-page";//로그인 페이지로 감!
}
Admin Token
UserService에 있음
// ADMIN_TOKEN
private final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";
일반 사용자인지 관리자인지 구분하기 위해서 토큰을 줄거래
실제로는 이렇게 주지는 않음
UserRepository에 코드 추가
package com.sparta.springauth.repository;
import com.sparta.springauth.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User,Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
}
UserService
package com.sparta.springauth.service;
import com.sparta.springauth.dto.SignupRequestDto;
import com.sparta.springauth.entity.User;
import com.sparta.springauth.entity.UserRoleEnum;
import com.sparta.springauth.repository.UserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
// ADMIN_TOKEN
private final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";
public void signup(SignupRequestDto requestDto) {//회원가입할 객체를 받아옴
String username = requestDto.getUsername();//Username을 뽑는다
String password = passwordEncoder.encode(requestDto.getPassword());//password encoding(암호화)함
// 회원 중복 확인
Optional<User> checkUsername = userRepository.findByUsername(username);//Optional : null체크
if (checkUsername.isPresent()) {//현재 Optional에 넣어준 값이 존재하는지 아닌지 확인해줌
throw new IllegalArgumentException("중복된 사용자가 존재합니다.");//존재하면 중복이라 처리해줌
}
//-> 회원 중복이 아니라면 email 코드로 넘어감
// email 중복확인
String email = requestDto.getEmail();
Optional<User> checkEmail = userRepository.findByEmail(email);
if (checkEmail.isPresent()) {
throw new IllegalArgumentException("중복된 Email 입니다.");
}
// 사용자 ROLE 확인
UserRoleEnum role = UserRoleEnum.USER;
if (requestDto.isAdmin()) {//admin boolean 타입 변수의 getter
if (!ADMIN_TOKEN.equals(requestDto.getAdminToken())) {
throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
}
role = UserRoleEnum.ADMIN;
}
// 사용자 등록
User user = new User(username, password, email, role);//등록을 하려면 객체를 만들어야 함 db의 한 row= 해당하는 entity class의 한 객체
userRepository.save(user);
}
}
User에 생성자 추가
public User(String username, String password, String email, UserRoleEnum role) {
this.username = username;
this.password = password;
this.email = email;
this.role = role;
}
08. 로그인 구현 : JWT
로그인 API 설계
Name Method URL
회원가입 페이지 | GET | /api/user/login-page | 회원가입 페이지 호출 |
회원가입 | POST | /api/user/login | 회원가입 |
UserController에 다음 코드 추가
@PostMapping("/user/login")
public String login(@ModelAttribute LoginRequestDto requestDto, HttpServletResponse res){//JWT 토큰을 생성 후 쿠키에 담아서 Response객체에 넣음
userService.login(requestDto,res);//로그인할때 검증하라고 받아온 request데이터 보내주고 검증 후 JWT토큰 쿠키에 넣고 쿠키를 담으라고 response객체도 보내줌
return "redirect:/";//로그인에 성공한다면 메인으로 redirect
→*문제가 생겼을때 로그인 페이지에 에러표시 해줌 → try catch문으로 감싸봄*
userService.login(requestDto,res). → 점 찍으면 try 뜨는데 그거 누르면 바로 try catch문으로 감싸짐
@PostMapping("/user/login")
public String login(@ModelAttribute LoginRequestDto requestDto, HttpServletResponse res){//JWT 토큰을 생성 후 쿠키에 담아서 Response객체에 넣음
try {
userService.login(requestDto,res);
} catch (Exception e) {
//e.getMessage()//이런식으로 로그 찍어도 됨
return "redirect:/api/user/login-page";//로그인 페이지로 redirect
}
return "redirect:/";//로그인에 성공한다면 메인으로 redirect
}
return "redirect:/api/user/login-page?error";
오류가 들어왔을때 이렇게 해달라고 클라이언트에서 요청이 들어옴
어떻게 되는지 이따가 봄
UserService에 함수 생성
public void login(LoginRequestDto requestDto, HttpServletResponse res) {
String username = requestDto.getUsername();
String password = requestDto.getPassword();
//사용자 확인, 회원정보확인이랑 반대 없으면 에러처리, 따로 메서드 안 빼고 에러처리 안에서 진행
User user = userRepository.findByUsername(username).orElseThrow(
()-> new IllegalArgumentException("등록된 사용자가 없습니다")
);
//비밀번호 확인 matches(입력받은 비번, 암호화된 비번)
if(!passwordEncoder.matches(password,user.getPassword())){
//비밀번호가 일치하지 않으면 오류를 던짐
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다");
}
//JWT생성 및 쿠키에 저장 후 Response 객체에 추가
-> JwtUtil 받아온 후 코드 아래에 있음
}
JWT 사용하려면 :
Bean 으로 등록된 JwtUtil을 가지고 온다
- @RequiredArgsConstructor 이용
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
//이 부분부터 삭제함
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
//이 부분까지 삭제함
or
- 생성자 만듦→통일성을 위해 강의에서 채택
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder,JwtUtil jwtUtil) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtUtil = jwtUtil;
}
public void login(LoginRequestDto requestDto, HttpServletResponse res) {
String username = requestDto.getUsername();
String password = requestDto.getPassword();
//사용자 확인, 회원정보확인이랑 반대 없으면 에러처리, 따로 메서드 안 빼고 에러처리 안에서 진행
User user = userRepository.findByUsername(username).orElseThrow(
()-> new IllegalArgumentException("등록된 사용자가 없습니다")
);
//비밀번호 확인 matches(입력받은 비번, 암호화된 비번)
if(!passwordEncoder.matches(password,user.getPassword())){
//비밀번호가 일치하지 않으면 오류를 던짐
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다");
}
//JWT생성 및 쿠키에 저장 후 Response 객체에 추가-> JWT token을 만들어야 함
String token = jwtUtil.createToken(user.getUsername(),user.getRole());//JWT 생성이 구현된 메서드
jwtUtil.addJwtToCookie(token,res);//쿠키에 저장 후 Response 객체에 추가가 구현된 메서드
}
cookie 복사해서 jwt.io 가서
encoded에 붙여넣기 하면 decoded에 정보가 나옴
ctrl+b → JwtUtil에 createToken은 이렇게 만들어져 있음
// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
JwtUtil에 addJwtToToken은 이렇게 만들어져 있음
LoginRequestDto 생성
package com.sparta.springauth.dto;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class LoginRequestDto {
private String username;
private String password;
}
UserService
package com.sparta.springauth.service;
import com.sparta.springauth.dto.LoginRequestDto;
import com.sparta.springauth.dto.SignupRequestDto;
import com.sparta.springauth.entity.User;
import com.sparta.springauth.entity.UserRoleEnum;
import com.sparta.springauth.jwt.JwtUtil;
import com.sparta.springauth.repository.UserRepository;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtUtil jwtUtil) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtUtil = jwtUtil;
}
// ADMIN_TOKEN
private final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";
public void signup(SignupRequestDto requestDto) {
String username = requestDto.getUsername();
String password = passwordEncoder.encode(requestDto.getPassword());
// 회원 중복 확인
Optional<User> checkUsername = userRepository.findByUsername(username);
if (checkUsername.isPresent()) {
throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
}
// email 중복확인
String email = requestDto.getEmail();
Optional<User> checkEmail = userRepository.findByEmail(email);
if (checkEmail.isPresent()) {
throw new IllegalArgumentException("중복된 Email 입니다.");
}
// 사용자 ROLE 확인
UserRoleEnum role = UserRoleEnum.USER;
if (requestDto.isAdmin()) {
if (!ADMIN_TOKEN.equals(requestDto.getAdminToken())) {
throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
}
role = UserRoleEnum.ADMIN;
}
// 사용자 등록
User user = new User(username, password, email, role);
userRepository.save(user);
}
public void login(LoginRequestDto requestDto, HttpServletResponse res) {
String username = requestDto.getUsername();
String password = requestDto.getPassword();
// 사용자 확인
User user = userRepository.findByUsername(username).orElseThrow(
() -> new IllegalArgumentException("등록된 사용자가 없습니다.")
);
// 비밀번호 확인
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}
// JWT 생성 및 쿠키에 저장 후 Response 객체에 추가
String token = jwtUtil.createToken(user.getUsername(), user.getRole());
jwtUtil.addJwtToCookie(token, res);
}
}
09. 필터
Filter : Web 애플리케이션에서 관리되는 영역
Client로 부터 오는 요청과 응답에 대해 최초/최종 단계의 위치
이를 통해 요청과 응답의 정보를 변경하거나 부가적인 기능을 추가할 수 있음
주로 범용적으로 처리해야 하는 작업들, 예를들어 로깅 및 보안 처리에 활용함
- 또한 인증, 인가와 관련된 로직들을 처리할 수도 있음
- Filter를 사용하면 인증, 인가와 관련된 로직을 비즈니스 로직과 분리하여 관리할 수 있다는 장점이 있다
Filter Chain
Filter는 한 개만 존재하는 것이 아니라 이렇게 여러 개가 Chain 형식으로 묶여서 처리될 수 있다
Filter 적용
요청 URL의 인가 처리 및 인증 처리를 진행할 수 있는 Filter를 구현
- Request URL Logging./
filter > LoggingFilter
package com.sparta.springauth.filter;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Slf4j(topic = "LoggingFilter")
@Component
@Order(1)
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 전처리
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
log.info(url);
chain.doFilter(request, response); // 다음 Filter 로 이동
// 후처리
log.info("비즈니스 로직 완료");
}
}
- @Order(1) 로 필터의 순서를 지정
- chain.doFilter(request, response); 다음 Filter로 이동시킴
- log.info("비즈니스 로직 완료");
- 작업이 다 완료된 후 Client에 응답 전 로그가 작성된 것을 확인할 수 있음
- AuthFilter : 인증 및 인가 처리 필터
filter > AuthFilter
package com.sparta.springauth.filter;
import com.sparta.springauth.entity.User;
import com.sparta.springauth.jwt.JwtUtil;
import com.sparta.springauth.repository.UserRepository;
import io.jsonwebtoken.Claims;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
@Slf4j(topic = "AuthFilter")
@Component
@Order(2)
public class AuthFilter implements Filter {
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
public AuthFilter(UserRepository userRepository, JwtUtil jwtUtil) {
this.userRepository = userRepository;
this.jwtUtil = jwtUtil;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
if (StringUtils.hasText(url) &&
(url.startsWith("/api/user") || url.startsWith("/css") || url.startsWith("/js"))
) {
log.info("인증 처리를 하지 않는 url");
// 회원가입, 로그인 관련 API 는 인증 필요없이 요청 진행
chain.doFilter(request, response); // 다음 Filter 로 이동
} else {
// 나머지 API 요청은 인증 처리 진행
// 토큰 확인
String tokenValue = jwtUtil.getTokenFromRequest(httpServletRequest);
//옛날에는 cookievalue로 cookie에 들어있는 value값을 손쉽게 가져올 수 있었다고 함
if (StringUtils.hasText(tokenValue)) { // 토큰이 존재하면 검증 시작
// JWT 토큰 substring
String token = jwtUtil.substringToken(tokenValue);
// 토큰 검증
if (!jwtUtil.validateToken(token)) {
throw new IllegalArgumentException("Token Error");
}
// 토큰에서 사용자 정보 가져오기
Claims info = jwtUtil.getUserInfoFromToken(token);
User user = userRepository.findByUsername(info.getSubject()).orElseThrow(() ->
new NullPointerException("Not Found User")
);
request.setAttribute("user", user);
chain.doFilter(request, response); // 다음 Filter 로 이동
} else {
throw new IllegalArgumentException("Not Found Token");
}
}
}
}
인가 : httpServletRequest.getRequestURI() 요청 URL을 가져와서 구분
"/api/user", "/css", "/js" 로 시작하는 URL은 인증 처리에서 제외 시킴
그 외 URL은 인증 처리를 진행
jwtUtil.getTokenFromRequest(httpServletRequest);
- httpServletRequest 에서 Cookie 목록을 가져와 JWT가 저장된 Cookie를 찾음
- getTokenFromRequest 메서드를 JwtUtil에 구현함 → dispatcherservlet보다 앞단이라서 cookievalue가져오기를 못 해서 직접 구현해야 한다고 함
JwtUtil에 getTokenFromRequest 구현
// HttpServletRequest 에서 Cookie Value : JWT 가져오기
public String getTokenFromRequest(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();//여러개 담겨있던 쿠키들을 배열로 반환
if(cookies != null) {//null check
for (Cookie cookie : cookies) {
if (cookie.getName().equals(AUTHORIZATION_HEADER)) {//쿠키중에 이름이 우리가 가지고 오려고 하는 autorization인지 아닌지 체크함, 맞다면 가지고 옴
try {
return URLDecoder.decode(cookie.getValue(), "UTF-8"); // Encode 되어 넘어간 Value 다시 Decode 하여 돌려줌 : cookie에 들어있는 value리턴
} catch (UnsupportedEncodingException e) {
return null;
}
}
}
}
return null;
}
- tokenValue가 존재하면 토큰 파싱, 검증을 진행하고 사용자 정보를 가져옴
- 가져온 사용자 username을 사용해서 DB에 사용자가 존재하는지 확인하고 존재하면 인증이 완료된 것임
- 사용자 정보가 필요한 Controller API에 인증완료된 User 객체를 전달해 줌
ProductController
package com.sparta.springauth.controller;
import com.sparta.springauth.entity.User;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/api")
public class ProductController {
@GetMapping("/products")
public String getProducts(HttpServletRequest req) {
System.out.println("ProductController.getProducts : 인증 완료");
User user = (User) req.getAttribute("user");//request객체에 아까 넣은 user뽑음
System.out.println("user.getUsername() = " + user.getUsername());
return "redirect:/";
}
}
- 사용자 본인이 등록한 제품만 조회하는 기능의 API라 가정해봄
- Filter에서 인증 처리되어 넘어온 User 객체를 사용하면 API 요청을 한 해당 사용자가 등록한 제품만 조회할 수 있음
Spring Security 프레임워크
Spring Security 적용
'Spring Security' 프레임워크 : Spring 서버에 필요한 인증 및 인가를 위해 많은 기능을 제공해 줌으로써 개발의 수고를 덜어줌
마치 'Spring' 프레임워크가 웹 서버 구현에 편의를 제공해 주는 것과 같다
build.gradle에 'Spring Security' 프레임워크 추가
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
'Spring Security' 활성화
@SpringBootApplication(exclude= SecurityException.class)//Spring Security 인증 기능 해제
Spring Security 제외 해제
//@SpringBootApplication(exclude= SecurityException.class)//Spring Security 인증 기능 해제
//-> 인증 기능 해제를 해제함!
@SpringBootApplication
public class SpringAuthApplication {
public static void main(String[] args) {
SpringApplication.run(SpringAuthApplication.class, args);
}
}
- 'Spring Security' 설정
WebSecurityConfig
package com.sparta.springauth.config;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
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.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
// 로그인 사용
http.formLogin(Customizer.withDefaults());
return http.build();
}
}
http.authorizeHttpRequests((authorizeHttpRequests) ->
<resources 접근 허용 설정> main → resources(static,templates)에 들어오는 값들을 설정
authorizeHttpRequests
.requestMatchers(
PathRequest.
*toStaticResources*() 위치 resources(static,templates)
.atCommonLocations()) 위치 resources(static,templates)
.permitAll() 모든 것을 허가함, 이쪽으로 인증처리가 들어오면 따로 인증처리를 하지 않아도 그냥 무조건 다 허가
아까 path가 user로 시작되는 모든 요청들 (로그인, 회원가입)도 필터처리 했었음
.requestMatchers("/api/user/**").permitAll()
이렇게 하면 됨
전체 코드
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());//사용하지 않겠다
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
**.requestMatchers("/api/user/**").permitAll()**
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
// 로그인 사용
http.formLogin(Customizer.withDefaults());//Spring Security 제공 디폴트 로그인, 페이지까지 나옴
return http.build();
}
}
chrome→settings→privacy and security→clear browsing data→caches images and files delete
JSONID 를 개발자 툴에서 지우는 순간 session id 없어져서 튕김
spring 켤때 뜬 user랑 비번으로 브라우저에서 로그인! 그거 나오게 해놨나봄
Spring Security는 기본적으로 session 방식으로 동작하고 있음
.anyRequest().authenticated() // 그 외 모든 요청 인증처리);
LoggingFilter, AuthFilter 등록 해제
LoggingFilter
@Slf4j(topic = "LoggingFilter")
//@Component
@Order(1)
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 전처리
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
log.info(url);
chain.doFilter(request, response); // 다음 Filter 로 이동
// 후처리
log.info("비즈니스 로직 완료");
}
}
AuthFilter
@Slf4j(topic = "AuthFilter")
//@Component
@Order(2)
public class AuthFilter implements Filter {
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
public AuthFilter(UserRepository userRepository, JwtUtil jwtUtil) {
this.userRepository = userRepository;
this.jwtUtil = jwtUtil;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
if (StringUtils.hasText(url) &&
(url.startsWith("/api/user") || url.startsWith("/css") || url.startsWith("/js"))
) {
// 회원가입, 로그인 관련 API 는 인증 필요없이 요청 진행
chain.doFilter(request, response); // 다음 Filter 로 이동
} else {
// 나머지 API 요청은 인증 처리 진행
// 토큰 확인
String tokenValue = jwtUtil.getTokenFromRequest(httpServletRequest);
if (StringUtils.hasText(tokenValue)) { // 토큰이 존재하면 검증 시작
// JWT 토큰 substring
String token = jwtUtil.substringToken(tokenValue);
// 토큰 검증
if (!jwtUtil.validateToken(token)) {
throw new IllegalArgumentException("Token Error");
}
// 토큰에서 사용자 정보 가져오기
Claims info = jwtUtil.getUserInfoFromToken(token);
User user = userRepository.findByUsername(info.getSubject()).orElseThrow(() ->
new NullPointerException("Not Found User")
);
request.setAttribute("user", user);
chain.doFilter(request, response); // 다음 Filter 로 이동
} else {
throw new IllegalArgumentException("Not Found Token");
}
}
}
}
CSRF란?
- CSRF(사이트 간 요청 위조, Cross-site request forgery)
- 공격자가 인증된 브라우저에 저장된 쿠키의 세션 정보를 활용하여 웹 서버에 사용자가 의도하지 않은 요청을 전달하는 것
- CSRF 설정이 되어있는 경우 html 에서 CSRF 토큰 값을 넘겨주어야 요청을 수신 가능함
- 쿠키 기반의 취약점을 이용한 공격 이기 때문에 REST 방식의 API 에서는 disable 가능함
- POST 요청마다 처리해 주는 대신 CSRF protection 을 disable 함
- http.csrf((csrf) -> csrf.disable());
Spring Security의 default 로그인 기능
- Username: user
- Password: Spring 로그 확인 (서버 시작 시마다 변경됨)
@Slf4j(topic = "LoggingFilter")
//@Component
@Order(1)
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 전처리
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
log.info(url);
chain.doFilter(request, response); // 다음 Filter 로 이동
// 후처리
log.info("비즈니스 로직 완료");
}
}
@Slf4j(topic = "LoggingFilter")
//@Component
@Order(1)
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 전처리
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
log.info(url);
chain.doFilter(request, response); // 다음 Filter 로 이동
// 후처리
log.info("비즈니스 로직 완료");
}
}
@Slf4j(topic = "LoggingFilter")
//@Component
@Order(1)
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 전처리
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
log.info(url);
chain.doFilter(request, response); // 다음 Filter 로 이동
// 후처리
log.info("비즈니스 로직 완료");
}
}
Spring Security - Filter Chain
Spring에서 모든 호출은 DispatcherServlet을 통과하게 되고 이후에 각 요청을 담당하는 Controller 로 분배됨
Filter : 각 요청에 대해서 공통적으로 처리해야할 필요가 있을 때 DispatcherServlet 전에 전처리
Spring Security도 인증 및 인가를 처리하기 위해 Filter를 사용 , FilterChainProxy를 통해서 상세로직을 구현하고 있음
Form Login 기반 인증
Form Login 기반 인증 : 인증이 필요한 URL 요청이 들어왔을 때 인증이 되지 않았다면 로그인 페이지를 반환하는 형태
계속 로그인 페이지로 보내버림
UsernamePasswordAuthenticationFilter
- UsernamePasswordAuthenticationFilter는 Spring Security의 필터인 AbstractAuthenticationProcessingFilter를 상속한 Filter입니다.
- 기본적으로 Form Login 기반을 사용할 때 username 과 password 확인하여 인증합니다.
- 인증 과정
- 사용자가 username과 password를 제출하면 UsernamePasswordAuthenticationFilter는 인증된 사용자의 정보가 담기는 인증 객체인 Authentication의 종류 중 하나인 UsernamePasswordAuthenticationToken을 만들어 AuthenticationManager에게 넘겨 인증을 시도합니다.
- 실패하면 SecurityContextHolder를 비웁니다.
- 성공하면 SecurityContextHolder에 Authentication를 세팅함
SecurityContextHolder
- SecurityContext는 인증이 완료된 사용자의 상세 정보(Authentication)를 저장함
- SecurityContext는 SecurityContextHolder 로 접근할 수 있음
// 예시코드
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
context.setAuthentication(authentication); // SecurityContext 에 인증 객체 Authentication 를 저장합니다.
SecurityContextHolder.setContext(context);
Authentication
- 현재 인증된 사용자를 나타내며 SecurityContext에서 가져올 수 있다
- principal : 사용자를 식별
- Username/Password 방식으로 인증할 때 일반적으로 UserDetails 인스턴스를 넣음
- credentials : 주로 비밀번호, 대부분 사용자 인증에 사용한 후 비움
- authorities : 사용자에게 부여한 권한을 GrantedAuthority로 추상화하여 사용, 나중에 Security에서 제공하는 권한 기능이 있는데 요청을 허락할지 아닐지 손쉽게 처리하는 방법이 있음 → 그것때문에 Authority 기능을 사용해봄
<UserDetails>
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
<aside> 💡 UsernamePasswordAuthenticationToken는 Authentication을 implements한 AbstractAuthenticationToken의 하위 클래스로, 인증객체를 만드는데 사용됨
</aside>
UserDetailsService
<aside> 💡 UserDetailsService는 username/password 인증방식을 사용할 때 사용자를 조회하고 검증한 후 UserDetails를 반환합니다. Custom하여 Bean으로 등록 후 사용 가능합니다.
</aside>
UserDetails
<aside> 💡 검증된 UserDetails는 UsernamePasswordAuthenticationToken 타입의 Authentication를 만들 때 사용되며 해당 인증객체는 SecurityContextHolder에 세팅됩니다. Custom하여 사용가능합니다.
</aside>
11. Spring Security : 로그인
로그인 처리 과정
Spring Security 사용 전
Client → (Request) → Controller → (Request) → Service
Client ← (Response) ← Controller ← (Response) ← Service
Spring Security 사용 후
인증/인가 성공시
Client → (Request) → Spring Security → (Request + UserDetails) → Controller → Service
Client ← (Response) ← Controller ← (Response) ← Service
인증/인가 실패시
Client → (Request) → Spring Security
Client ← (Error Response) ← Spring Security
- Client 의 요청은 모두 Spring Security 를 거쳐감
- Spring Security 역할
- 인증/인가
- 성공 시: Controller 로 Client 요청 전달
- Client 요청 + 사용자 정보 (UserDetails)
- 실패 시: Controller 로 Client 요청 전달되지 않음
- Client 에게 Error Response 보냄
- 성공 시: Controller 로 Client 요청 전달
- 인증/인가
로그인 처리 과정
Client → (로그인 시도) → Authentication Manager → (회원 ID) → User Details Service ↔ 회원정보 DB
회원정보 DB조회 성공 시
Authentication Manager ← (UserDetails 생성, 전달) ← User Details Service
회원정보 DB조회 실패 시
Error 발생
로그인 시도와 UserDetails의 username,password일치 시
Client ← (Session 생성,전달) ← Authentication Manager
불일치시
Error 발생
상세 처리 과정 설명
- Client
- 로그인 시도
- 로그인 시도할 username, password 정보를 HTTP body 로 전달 (POST 요청)
- 로그인 시도 URL 은 WebSecurityConfig 클래스에서 변경 가능
- 아래와 같이 설정 시 "POST /api/user/login" 로 설정됨
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // CSRF 설정 http.csrf((csrf) -> csrf.disable()); http.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정 .anyRequest().authenticated() // 그 외 모든 요청 인증처리 ); // 로그인 사용 http.formLogin((formLogin) -> formLogin // 로그인 처리 (POST /api/user/login) **.loginProcessingUrl("/api/user/login")**.permitAll() ); return http.build(); }
- 인증 관리자 (Authentication Manager)
- UserDetailsService 에게 username 을 전달하고 회원상세 정보를 요청
- UserDetailsService
- 회원 DB 에서 회원 조회
- 회원 정보가 존재하지 않을 시 → Error 발생
- User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
- 조회된 회원 정보(user) 를 UserDetails 로 변환
- UserDetails userDetails = new UserDetailsImpl(user)
- UserDetails 를 **"인증 관리자"**에게 전달
- 회원 DB 에서 회원 조회
- "인증 관리자" 가 인증 처리
- 아래 2 개의 username, password 일치 여부 확인
- Client 가 로그인 시도한 username, password
- UserDetailsService 가 전달해준 UserDetails 의 username, password
- password 비교 시
- Client 가 보낸 password 는 평문이고, UserDetails 의 password 는 암호문
- Client 가 보낸 password 를 암호화해서 비교
- 인증 성공 시 → 세션에 로그인 정보 저장
- 인증 실패 시 → Error 발생
- 아래 2 개의 username, password 일치 여부 확인
로그인 구현
- 로그인 처리 URL 설정
package com.sparta.springauth.config; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; 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.web.SecurityFilterChain; @Configuration @EnableWebSecurity // Spring Security 지원을 가능하게 함 public class WebSecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // CSRF 설정 http.csrf((csrf) -> csrf.disable()); http.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정 .requestMatchers("/api/user/**").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가 .anyRequest().authenticated() // 그 외 모든 요청 인증처리 ); // 로그인 사용 http.formLogin((formLogin) -> formLogin // 로그인 View 제공 (GET /api/user/login-page) .loginPage("/api/user/login-page") // 로그인 처리 (POST /api/user/login) .loginProcessingUrl("/api/user/login") // 로그인 처리 후 성공 시 URL .defaultSuccessUrl("/") // 로그인 처리 후 실패 시 URL .failureUrl("/api/user/login-page?error") .permitAll() ); return http.build(); } }
- 우리가 직접 Filter를 구현해서 URL 요청에 따른 인가를 설정한다면 코드가 매우 복잡해지고 유지보수 비용이 많이 듦
- Spring Security를 사용하면 이러한 인가 처리가 굉장히 편해짐
- 인증이 필요 없는 URL들을 간편하게 허가
- requestMatchers("/api/user/**").permitAll()
- 이 요청들은 로그인, 회원가입 관련 요청이기 때문에 비회원/회원 상관없이 누구나 접근이 가능해야 함!
- 인증이 필요한 URL들도 간편하게 처리
- anyRequest().authenticated()
- 이제 디폴트 로그인 페이지가 아니라 지정한 로그인 페이지로 감
// 로그인 사용 http.formLogin((formLogin) -> formLogin // 로그인 View 제공 (GET /api/user/login-page) .loginPage("/api/user/login-page") // 로그인 처리 (POST /api/user/login) .loginProcessingUrl("/api/user/login") // 로그인 처리 후 성공 시 URL .defaultSuccessUrl("/") // 로그인 처리 후 실패 시 URL .failureUrl("/api/user/login-page?error") .permitAll() );
- 인증이 필요 없는 URL들을 간편하게 허가
- WebSecurityConfig
- DB의 회원 정보 조회 → Spring Security의 "인증 관리자" 에게 ****전달
- UserDetailsService 구현
- UserDetailsService 인터페이스 → UserDetailsServiceImpl
- security > UserDetailsServiceImpl
- UserDetails 구현
- UserDetails 인터페이스 → UserDetailsImpl
- security > UserDetailsImpl
- UserDetailsService 구현
package com.sparta.springauth.security;
import com.sparta.springauth.entity.User;
import com.sparta.springauth.repository.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
return new UserDetailsImpl(user);
}
}
package com.sparta.springauth.security;
import com.sparta.springauth.entity.User;
import com.sparta.springauth.entity.UserRoleEnum;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
public User getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
- UserDetailsService와 UserDetails를 직접 구현해서 사용하게 되면 Security의 default 로그인 기능을 사용하지 않겠다는 설정이 되어 Security의 password를 더 이상 제공하지 않는 것을 확인할 수 있습니다.
- POST "/api/user/login" 을 로그인 인증 URL로 설정했기 때문에 이제 해당 요청이 들어오면 우리가 직접 구현한 UserDetailsService를 통해 인증 확인 작업이 이뤄지고 인증 객체에 직접 구현한 UserDetails가 담기게 됩니다.
basic.js 수정
$(document).ready(function () {
const auth = getToken();
if(auth === '') {
window.location.href = host + "/api/user/login-page";
} else {
$('#login-true').show();
$('#login-false').hide();
}
})
→
$(document).ready(function () {
const auth = getToken();
if(auth === '') {
$('#login-true').show();
$('#login-false').hide();
//window.location.href = host + "/api/user/login-page";
} else {
$('#login-true').show();
$('#login-false').hide();
}
})
LoggingFilter의 Component 주석처리
@Slf4j(topic = "LoggingFilter")//topic으로 값을 주면 logging 찍힐때 이 이름으로 찍힘
**//@Component**
@Order(1)//Filter에 순서를 준다 LoggingFilter가 인증/인가 필터보다 먼저 실행되게 함
public class LoggingFilter implements Filter {
ProductController
user를 가져와서 사용했었음
@GetMapping("/products")
public String getProducts(HttpServletRequest req) {
System.out.println("ProductController.getProducts : 인증 완료");
User user = (User) req.getAttribute("user");//request객체에 아까 넣은 user뽑음
System.out.println("user.getUsername() = " + user.getUsername());
return "redirect:/";
}
→
훨씬 편한 방식 : Security Context Holder 안의 Security Context 안의 Authentication 안에 Principal, Credentials, Autorities중의 Principal 안에 저장된 UserDetails 에서 유저 뽑아옴
@GetMapping("/products")
public String getProducts(@AuthenticationPrincipal UserDetailsImpl userDetails) {
// Authentication 의 Principal 에 저장된 UserDetailsImpl 을 가져옵니다.
User user = userDetails.getUser();
System.out.println("user.getUsername() = " + user.getUsername());
return "redirect:/";
}
- @AuthenticationPrincipal
@Controller
@RequestMapping("/api")
public class ProductController {
@GetMapping("/products")
public String getProducts(@AuthenticationPrincipal UserDetailsImpl userDetails) {
// Authentication 의 Principal 에 저장된 UserDetailsImpl 을 가져옵니다.
User user = userDetails.getUser();
System.out.println("user.getUsername() = " + user.getUsername());
return "redirect:/";
}
}
sysout 꿀팁 : user.getUsername(); 이걸 print하고 싶으면 user.getUsername().soutv enter →
System.*out*.println("user.getUsername() = " + user.getUsername());
이렇게 됨 ㅋ 개꿀이네 sout도 된대?
→ user.getUsername() = Robbie 이렇게 찍힘
→ email도 .getEmail로 찍으면 찍힌다
12. Spring Security : JWT 로그인
Security Filter 순서 확인하기
로그인 페이지 수정
- login.html
-
Log into Select Shop
아이디비밀번호
인증 인가 처리와 비즈니스 로직 처리를 분리해야 필터를 쓰는 의미가 있다고 함
UserController쪽에서 로그인 구현하지 않는다고 함
UserController의 login method 삭제
@PostMapping("/user/login")
public String login(@ModelAttribute LoginRequestDto requestDto, HttpServletResponse res){//JWT 토큰을 생성 후 쿠키에 담아서 Response객체에 넣음
try {
userService.login(requestDto,res);
} catch (Exception e) {
return "redirect:/api/user/login-page?error";//로그인 페이지로 redirect
}
return "redirect:/";//로그인에 성공한다면 메인으로 redirect
}
UserService의 login method 삭제
public void login(LoginRequestDto requestDto, HttpServletResponse res) {
String username = requestDto.getUsername();
String password = requestDto.getPassword();
//사용자 확인, 회원정보확인이랑 반대 없으면 에러처리, 따로 메서드 안 빼고 에러처리 안에서 진행
User user = userRepository.findByUsername(username).orElseThrow(
()-> new IllegalArgumentException("등록된 사용자가 없습니다")
);
//비밀번호 확인 matches(입력받은 비번, 암호화된 비번)
if(!passwordEncoder.matches(password,user.getPassword())){
//비밀번호가 일치하지 않으면 오류를 던짐
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다");
}
//JWT생성 및 쿠키에 저장 후 Response 객체에 추가-> JWT token을 만들어야 함
String token = jwtUtil.createToken(user.getUsername(),user.getRole());//JWT 생성이 구현된 메서드
jwtUtil.addJwtToCookie(token,res);//쿠키에 저장 후 Response 객체에 추가가 구현된 메서드
}
로그인과정 처리하는 필터들 만듦
JWT 인증 처리 (Filter)
JwtAuthenticationFilter : 로그인 진행 및 JWT 생성
- JwtAuthenticationFilter
package com.sparta.springauth.jwt;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.springauth.dto.LoginRequestDto;
import com.sparta.springauth.entity.UserRoleEnum;
import com.sparta.springauth.security.UserDetailsImpl;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/user/login");
}
@Override//재정의
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
log.info("로그인 시도");
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(//인증처리를 하는 메서드
new UsernamePasswordAuthenticationToken(//인증 객체에 토큰을 넣어줘야 함 AuthenticationManager그림 참조
requestDto.getUsername(),
requestDto.getPassword(),
null//인증처리할때 필요 없어서 권한쪽은 그냥 null을 줌, (username,password,authority)가 생성자에 들어가고 있음
)
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
@Override//로그인 성공했을때 실행되는 메서드 오버라이딩
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
log.info("로그인 성공 및 JWT 생성");
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();//Authentication authResult 에서 UserDetails를 뽑아옴
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String token = jwtUtil.createToken(username, role);
jwtUtil.addJwtToCookie(token, response);//토큰을 생성해서 response객체에 넣어줌
}
@Override//로그인 실패했을때 실행되는 메서드 오버라이딩
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
log.info("로그인 실패");
response.setStatus(401);//401:unauthorized
}
}
이번에는 아까와 다르게 Session 방식 말고 JWT로 해서 직접 커스텀해서 구현해야 한다고 함
아까 이렇게 했었는데
// 로그인 사용
http.formLogin((formLogin) ->
formLogin
// 로그인 View 제공 (GET /api/user/login-page)
.loginPage("/api/user/login-page")
**// 로그인 처리 (POST /api/user/login)
.loginProcessingUrl("/api/user/login")**
// 로그인 처리 후 성공 시 URL
.defaultSuccessUrl("/")
// 로그인 처리 후 실패 시 URL
.failureUrl("/api/user/login-page?error")
.permitAll()
);
이제 이렇게 함
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
**setFilterProcessesUrl("/api/user/login");**
}
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
ObjectMapper : JSON 형태의 데이터를 Object로 바꿔줌
request.getInputStream() : 받아오는 데이터 LoginRequestDto.class 이 형태로 바꾸겠다
JWT 인가 처리 (Filter)
- JwtAuthorizationFilter : API에 전달되는 JWT 유효성 검증 및 인가 처리
- JwtAuthorizationFilter필터만 만들었다고 끝이 아님그걸 WebSecurityConfig에서 해줄거임
- WebSecurityConfig 필터 등록
- 필터를 등록하고 추가적인 값들도 줘야 함 JWT, UserDetailService어쩌고,
WebSecurityConfig 필터 등록
- WebSecurityConfig
다음으로 수정package com.sparta.springauth.config; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; 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.web.SecurityFilterChain; @Configuration @EnableWebSecurity // Spring Security 지원을 가능하게 함 public class WebSecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // CSRF 설정 http.csrf((csrf) -> csrf.disable()); http.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정 .requestMatchers("/api/user/**").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가 .anyRequest().authenticated() // 그 외 모든 요청 인증처리 ); // 로그인 사용 http.formLogin((formLogin) -> formLogin // 로그인 View 제공 (GET /api/user/login-page) .loginPage("/api/user/login-page") // 로그인 처리 (POST /api/user/login) .loginProcessingUrl("/api/user/login") // 로그인 처리 후 성공 시 URL .defaultSuccessUrl("/") // 로그인 처리 후 실패 시 URL .failureUrl("/api/user/login-page?error") .permitAll() ); return http.build(); } }
package com.sparta.springauth.config;
import com.sparta.springauth.jwt.JwtAuthorizationFilter;
import com.sparta.springauth.jwt.JwtAuthenticationFilter;
import com.sparta.springauth.jwt.JwtUtil;
import com.sparta.springauth.security.UserDetailsServiceImpl;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
public WebSecurityConfig(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService, AuthenticationConfiguration authenticationConfiguration) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
this.authenticationConfiguration = authenticationConfiguration;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.requestMatchers("/api/user/**").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
http.formLogin((formLogin) ->
formLogin
.loginPage("/api/user/login-page").permitAll()
);
// 필터 관리
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
HomeController 수정
@Controller
public class HomeController {
@GetMapping("/")
public String home(Model model) {
model.addAttribute("username", "username");
return "index";
}
}
→ 고정된 username이 아니라 실제 로그인을 한 유저의 이름을 가져옴
@Controller
public class HomeController {
@GetMapping("/")
public String home(Model model, @AuthenticationPrincipal UserDetailsImpl userDetails) {
model.addAttribute("username", userDetails.getUsername());
return "index";
}
}
13. 접근 불가 페이지 만들기
API 접근 권한 제어 이해
<aside> 👉 '일반 사용자'는 관리자 페이지에 접속이 인가되지 않아야 합니다!!
</aside>
Spring Security에 "권한 (Authority)" 설정방법
- 회원 상세정보 (UserDetailsImpl) 를 통해 "권한 (Authority)" 설정 가능합니다.
- 권한을 1개 이상 설정 가능합니다.
- "권한 이름" 규칙
- "ROLE_" 로 시작해야 함
"**USER**" 권한 부여 → "**ROLE_USER**"
- 예) "ADMIN" 권한 부여 → "ROLE_ADMIN"
- "ROLE_" 로 시작해야 함
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";
}
}
public class UserDetailsImpl implements UserDetails {
// ...
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
SimpleGrantedAuthority adminAuthority = new SimpleGrantedAuthority("ROLE_ADMIN");
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(adminAuthority);
return authorities;
}
}
- new SimpleGrantedAuthority("ROLE_ADMIN");
- 예시 코드는 ROLE_ADMIN 으로 고정되어 있지만 아래와 같이 실제 코드에서는 사용자에 저장되어있는 role의 authority 값을 사용하여 동적으로 저장됩니다.
UserRoleEnum role = user.getRole(); String authority = role.getAuthority(); SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
- UserDetailsImpl 저장된 authorities 값을 사용하여 간편하게 권한을 제어할 수 있습니다.
Spring Security를 이용한 API 별 권한 제어 방법
Controller 에 "@Secured" 애너테이션으로 권한 설정이 가능합니다.
@Secured("권한 이름") 선언
권한 1개 이상 설정 가능
Secured ****테스트 → ProductController에 붙여넣음
@Secured(UserRoleEnum.Authority.ADMIN) // 관리자용
@GetMapping("/products/secured")
public String getProductsByAdmin(@AuthenticationPrincipal UserDetailsImpl userDetails) {
System.out.println("userDetails.getUsername() = " + userDetails.getUsername());
for (GrantedAuthority authority : userDetails.getAuthorities()) {
System.out.println("authority.getAuthority() = " + authority.getAuthority());
}
return "redirect:/";
}
"@Secured" 애너테이션 활성화 방법
@Configuration
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
**@EnableGlobalMethodSecurity(securedEnabled = true)** // @Secured 애너테이션 활성화
public class WebSecurityConfig {
접근 불가 페이지 적용
<aside> 💡 스프링 시큐리티 설정을 이용해 일반 사용자가 '관리자용 상품조회 API' 에 접속 시도 시 접속 불가 페이지가 뜨도록 구현해 봅시다.
</aside>
- 프론트엔드 개발자 작업 → Forbidden 페이지 적용
resources > static > forbidden.html
var lock = document.querySelector('#lock');
var key = document.querySelector('#key');
function keyAnimate(){
dynamics.animate(key, {
translateX: 33
}, {
type:dynamics.easeInOut,
duration:500,
complete:lockAnimate
})
}
function lockAnimate(){
dynamics.animate(lock, {
rotateZ:-5,
scale:0.9
}, {
type:dynamics.bounce,
duration:3000,
complete:keyAnimate
})
}
setInterval(keyAnimate, 3000);
403 FORBIDDEN
사용자 접근 불가 페이지입니다.
WebSecurityConfig 파일 수정
"접근 불가" 페이지 URL 설정 → "/forbidden.html"
WebSecurityConfig
package com.sparta.springauth.config;
import com.sparta.springauth.jwt.JwtAuthorizationFilter;
import com.sparta.springauth.jwt.JwtAuthenticationFilter;
import com.sparta.springauth.jwt.JwtUtil;
import com.sparta.springauth.security.UserDetailsServiceImpl;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
public WebSecurityConfig(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService, AuthenticationConfiguration authenticationConfiguration) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
this.authenticationConfiguration = authenticationConfiguration;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.requestMatchers("/api/user/**").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
http.formLogin((formLogin) ->
formLogin
.loginPage("/api/user/login-page").permitAll()
);
// 필터 관리
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// 접근 불가 페이지
http.exceptionHandling((exceptionHandling) ->
exceptionHandling
// "접근 불가" 페이지 URL 설정
.accessDeniedPage("/forbidden.html")
);
return http.build();
}
}
데이터 검증하기
14. Validation
Validation이란?
data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==
프로그래밍을 하는데에 있어서 가장 중요한 부분 중 하나입니다.
특히나 Java는 null 값에 대한 접근에 대해 NullPointerException 오류가 발행하기 때문에 이러한 부분을 예방하기 위해 Validation 즉, 검증 과정이 필요합니다.
Spring에서는 null 확인 뿐 아니라 문자의 길이 측정과 같은 다른 검증 과정도 쉽게 처리할 수 있도록 Bean Validation 제공하고 있습니다.
Bean Validation
간편하게 사용할 수 있는 여러 애너테이션을 제공 해줍니다.
제공되는 애너테이션 일부를 학습해보겠습니다.
@NotNull null 불가
@NotEmpty | null, “” 불가 |
@NotBlank | null, “”. “ “ 불가 |
@Size | 문자 길이 측정 |
@Max | 최대값 |
@Min | 최소값 |
@Positive | 양수 |
@Negative | 음수 |
E-mail 형식 | |
@Pattern | 정규 표현식 |
build.gradle : validation library추가
implementation 'org.springframework.boot:spring-boot-starter-validation'
ProductRequestDto
package com.sparta.springauth.dto;
import jakarta.validation.constraints.*;
import lombok.Getter;
@Getter
public class ProductRequestDto {
@NotBlank
private String name;
@Email
private String email;
@Positive(message = "양수만 가능합니다.")
private int price;
@Negative(message = "음수만 가능합니다.")
private int discount;
@Size(min=2, max=10)
private String link;
@Max(10)
private int max;
@Min(2)
private int min;
}
@Valid
- Bean Validation을 적용한 해당 Object validation 실행
testValid → ProductController 아래에다 추가함
@PostMapping("/validation")
@ResponseBody
public ProductRequestDto testValid(@RequestBody @Valid ProductRequestDto requestDto) {
return requestDto;
}
@Valid를 객체에 꼭 달아줘야 검증이 일어남
JSON 데이터
{
"name" : "Robbie",
"email" : "Robbie@gmail.com",
"price" : 1234,
"discount" : -1234,
"link" : "54321",
"max" : 10,
"min" : 2
}
15. Validation 예외처리
회원가입 적용
SignupRequestDto
package com.sparta.springauth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class SignupRequestDto {
@NotBlank
private String username;
@NotBlank
private String password;
@Email
@NotBlank
private String email;
private boolean admin = false;
private String adminToken = "";
}
UserController : signup에 @Valid 적용
@PostMapping("/user/signup")
public String signup(@Valid SignupRequestDto requestDto) {
userService.signup(requestDto);
return "redirect:/api/user/login-page";
}
Validation 예외처리
회원가입 진행 시 데이터 검증 시 오류가 발생했을 때 로그인 페이지가 아니라 회원가입 페이지로 이동 하려면 Validation 예외를 처리해야합니다.
BindingResult
예외가 발생하면 BindingResult 객체에 오류에 대한 정보가 담깁니다.
파라미터로 BindingResult 객체를 받아올 수 있습니다.
UserController에 이거 추가
@PostMapping("/user/signup")
public String signup(@Valid SignupRequestDto requestDto, BindingResult bindingResult) {
// Validation 예외처리
//Valid 객체에 오류가 생기면 bindingResult에 담겨서 들어옴
List<FieldError> fieldErrors = bindingResult.getFieldErrors();//오류가 난 field들을 가지고 옴
//오류들 체크해서 로그를 찍어주고있다
if(fieldErrors.size() > 0) {
for (FieldError fieldError : bindingResult.getFieldErrors()) {
log.error(fieldError.getField()/*필드명*/ + " 필드 : " + fieldError.getDefaultMessage());
}
return "redirect:/api/user/signup";//오류가 하나 이상 발생해야 이 if문에 도니까 다시 하라고 회원가입 페이지로 보내줌
}
//fieldErrors.size==0일떄, 에러 없을때 수행됨
userService.signup(requestDto);
return "redirect:/api/user/login-page";
}
UserController
package com.sparta.springauth.controller;
import com.sparta.springauth.dto.LoginRequestDto;
import com.sparta.springauth.dto.SignupRequestDto;
import com.sparta.springauth.service.UserService;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.List;
@Slf4j
@Controller
@RequestMapping("/api")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/user/login-page")
public String loginPage() {
return "login";
}
@GetMapping("/user/signup")
public String signupPage() {
return "signup";
}
@PostMapping("/user/signup")
public String signup(@Valid SignupRequestDto requestDto, BindingResult bindingResult) {
// Validation 예외처리
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
if(fieldErrors.size() > 0) {
for (FieldError fieldError : bindingResult.getFieldErrors()) {
log.error(fieldError.getField() + " 필드 : " + fieldError.getDefaultMessage());
}
return "redirect:/api/user/signup";
}
userService.signup(requestDto);
return "redirect:/api/user/login-page";
}
}
- bindingResult.getFieldErrors()
- 발생한 오류들에 대한 정보가 담긴 List<FieldError> 리스트를 가져옵니다.
'TIL(Develop)' 카테고리의 다른 글
네이버 지도 API 사용하기 (0) | 2024.03.25 |
---|---|
클라이언트에서 데이터를 받아오는 방법 (2) | 2023.12.06 |
Spring Bean (0) | 2023.11.20 |
JPA in Spring Boot (0) | 2023.11.15 |
[JPA Core] (0) | 2023.11.15 |