본문 바로가기
TIL/Spring

23.06.19

by J1-H00N 2023. 6. 19.

gardle이 실행되는 경로상에 한글이 있으면 오류가 생겨서 사용자 이름, 바탕화면까지 전부 영어로 교체하는 과정을 거치느라 1시간 가량을 소비해버렸다...

 

Bean은 @Controller, @Service, @Repository등의 Annotation들로 자동으로 등록하는게 편하고, 개발 생산성에도 유리하다.

하지만 Bean을 수동으로 등록해야 할 때가 있는데, 기술적인 문제나 공통적인 관심사를 처리할 때 사용하는 객체들을 수동으로 등록하는 것이 좋다.

  • 공통 로그처리와 같은 비즈니스 로직을 지원하기 위한 부가 적이고 공통적인 기능들을 기술 지원 Bean이라 부르고 수동등록 합니다.
  • 비즈니스 로직 Bean 보다는 그 수가 적기 때문에 수동으로 등록하기 부담스럽지 않습니다.
  • 또한 수동등록된 Bean에서 문제가 발생했을 때 해당 위치를 파악하기 쉽다는 장점이 있습니다.

수동으로 등록하는 방법

  • Bean으로 등록하고자하는 객체를 반환하는 메서드를 선언하고 @Bean을 설정합니다.
  • Bean을 등록하는 메서드가 속한 해당 클래스에 @Configuration을 설정합니다.
  • Spring 서버가 뜰 때 Spring IoC 컨테이너에 'Bean'으로 저장됩니다.

예시

더보기
@Configuration
public class PasswordConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 

같은 이름의 Bean이 두 개 이상일 때 

첫 번째 bean 객체의 이름을 불러온다

더보기
public interface Food {
    void eat();
}
@Component
public class Chicken implements Food {
    @Override
    public void eat() {
        System.out.println("치킨을 먹습니다.");
    }
}
@Component
public class Pizza implements Food {
    @Override
    public void eat() {
        System.out.println("피자를 먹습니다.");
    }
}
@SpringBootTest
public class BeanTest {
//    @Autowired
//    Food food; // 같은 이름의 Bean이 두 개 이상이므로 오류
    @Autowired
    Food pizza; // 이름을 명시

    @Autowired
    Food chicken;

    @Test
    @DisplayName("테스트")
    void test1() {
        pizza.eat();
        chicken.eat();
    }

}

두 번째 @Primary를 사용하여 우선순위를 준다

더보기
@Component
@Primary
public class Chicken implements Food {
    @Override
    public void eat() {
        System.out.println("치킨을 먹습니다.");
    }
}
@SpringBootTest
public class BeanTest {
    @Autowired
    Food food; // @Primary가 붙어있는 Bean을 우선적으로 탐색
    @Test
    @DisplayName("테스트")
    void test1() {
        food.eat(); // 치킨을 먹습니다.
    }

}

세 번째 이름을 지정해서 명시한다

더보기
@Component
@Qualifier("pizza")
public class Pizza implements Food {
    @Override
    public void eat() {
        System.out.println("피자를 먹습니다.");
    }
}
@SpringBootTest
public class BeanTest {
    @Autowired
    @Qualifier("pizza") // 이름으로 명시
    Food food; // @Primary가 붙어있는 Bean을 우선적으로 탐색
    @Test
    @DisplayName("테스트")
    void test1() {
        food.eat(); // 치킨을 먹습니다.
    }

}

여기서 Qualifier가 Primary보다 우선순위는 높지만, Qualifier는 사용할 때마다 호출해야 하기 때문에 범용적으로 사용할 때는 Primary를 쓴다.

 

인증과 인가

인증 : 해당 유저가 실제 유저인지 인증하는 개념

인가 : 해당 유저가 특정 리소스에 접근이 가능한지 허가를 확인하는 개념

 

웹 애플리케이션 인증의 특수성

일반적으로 서버-클라이언트 구조로 되어있고, 실제로 이 두가지 요소는 아주 멀리 떨어져 있다

그리고 Http 라는 프로토콜을 이용하여 통신하는데, 그 통신은 비연결성(Connectionless) 무상태(Stateless)로 이루어진다

비연결성 : 서버와 클라이언트는 실시간으로 이어져있는게 아닌 필요할 때만 연결되어 요청과 응답을 주고받는 것이다. 그렇지 않으면 서버의 비용이 너무 늘어나기 때문이다.

무상태 : 서버가 클라이언트의 상태를 기억하고 있지 않는다. 기존의 상태를 기억해두는 것도 위와 마찬가지로 서버에 비용을 과하게 부담시키기 때문

 

인증의 방식

쿠키-세션 방식

쿠키와 세션 모두 HTTP 에 상태 정보를 유지(Stateful)하기 위해 사용

 

쿠키

클라이언트에 저장될 목적으로 생성한 작은 정보를 담은 파일

구성요소

  • Name (이름): 쿠키를 구별하는 데 사용되는 키 (중복될 수 없음)
  • Value (값): 쿠키의 값
  • Domain (도메인): 쿠키가 저장된 도메인
  • Path (경로): 쿠키가 사용되는 경로
  • Expires (만료기한): 쿠키의 만료기한 (만료기한 지나면 삭제됩니다.)

세션

서버에서 일정시간 동안 클라이언트 상태를 유지하기 위해 사용

서버에서 클라이언트 별로 유일무이한 '세션 ID' 를 부여한 후 클라이언트 별 필요한 정보를 서버에 저장

서버에서 생성한 '세션 ID' 는 클라이언트의 쿠키값('세션 쿠키' 라고 부름)으로 저장되어 클라이언트 식별에 사용

 

  1. 사용자가 로그인 요청을 보냅니다.
  2. 서버는 DB의 유저 테이블을 뒤져서 아이디 비밀번호를 대조해봐야겠죠?
  3. 실제 유저테이블의 정보와 일치한다면 인증을 통과한 것으로 보고 “세션 저장소”에 해당 유저가 로그인 되었다는 정보를 넣습니다.
  4. 세션 저장소에서는 유저의 정보와는 관련 없는 난수인 session-id를 발급합니다.
  5. 서버는 로그인 요청의 응답으로 session-id를 내어줍니다.
  6. 클라이언트는 그 session-id를 쿠키라는 저장소에 보관하고 앞으로의 요청마다 세션아이디를 같이 보냅니다. (주로 HTTP header에 담아서 보냅니다!)
  7. 클라이언트의 요청에서 쿠키를 발견했다면 서버는 세션 저장소에서 쿠키를 검증합니다.
  8. 만약 유저정보를 받아왔다면 이 사용자는 로그인이 되어있는 사용자겠죠?
  9. 이후에는 로그인 된 유저에 따른 응답을 내어줍니다.

 

JWT 기반 (쿠키-세션과 달리 세션 저장소가 없어 서버 부담이 줄어들어 더 효율적이다)

JWT : JSON 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token

사용이유

서버의 대용량 트래픽 처리를 위해 서버 2대 이상 운영이 필요할 수 있는데, 

Session 마다 다른 Client 로그인 정보를 가지고 있을 수 있다. 만약 Client 1의 로그인 정보를 가지고 있지 않은 Sever2 나 Server3 에 API 요청을 하게되면 문제가 발생하고 만다.

이를 해결하기 위해선 Session마다 요청 Server를 고정(Sticky Session)하거나, 세션 저장소 생성하여 모든 세션을 저장하면 된다.

세션 저장소를 생성하면 Session storage 가 모든 Client 의 로그인 정보 소유하고 있기 때문에 모든 서버에서 모든 Client의 API 요청을 처리할 수 있다.

JWT를 사용하면 로그인 정보를 Server 에 저장하지 않고, Client 에 로그인 정보를 JWT 로 암호화하여 저장 → JWT 통해 인증/인가한다.

JWT의 장단점

장점

  • 동시 접속자가 많을 때 서버 측 부하 낮춤
  • Client, Sever 가 다른 도메인을 사용할 때
    • 예) 카카오 OAuth2 로그인 시 JWT Token 사용

단점

  • 구현의 복잡도 증가
  • JWT 에 담는 내용이 커질 수록 네트워크 비용 증가 (클라이언트 → 서버)
  • 기 생성된 JWT 를 일부만 만료시킬 방법이 없음
  • Secret key 유출 시 JWT 조작 가능

JWT의 사용흐름

서버에서 "로그인 정보" → JWT 로 암호화 (Secret Key 사용) > 서버에서 직접 쿠키를 생성해 JWT를 담아 Client 응답에 전달(전달방법은 개발자가 직접 정함) > 브라우저 쿠키 저장소에 자동으로 JWT 저장됨

  1. 사용자가 로그인 요청을 보냅니다.
  2. 서버는 DB의 유저 테이블을 뒤져서 아이디 비밀번호를 대조해봐야겠죠?
  3. 실제 유저테이블의 정보와 일치한다면 인증을 통과한 것으로 보고 유저의 정보를 JWT로 암호화 해서 내보냅니다.
  4. 서버는 로그인 요청의 응답으로 jwt 토큰을 내어줍니다.
  5. 클라이언트는 그 토큰을 저장소에 보관하고 앞으로의 요청마다 토큰을 같이 보냅니다.
  6. 클라이언트의 요청에서 토큰을 발견했다면 서버는 토큰을 검증합니다.
  7. 이후에는 로그인 된 유저에 따른 응답을 내어줍니다.

 

JWT 사용예시

더보기

build.gardle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.0'
    id 'io.spring.dependency-management' version '1.1.0'
}

group = 'com.sparta'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // 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'

    // Security
    implementation 'org.springframework.boot:spring-boot-starter-security'

    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

application.properties

jwt.secret.key=7Iqk7YyM66W07YOA7L2U65Sp7YG065+9U3ByaW5n6rCV7J2Y7Yqc7YSw7LWc7JuQ67mI7J6F64uI64ukLg==

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";
    }
}

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 {
    // JWT 데이터
    // 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);
    }

    // JWT 생성
    // 토큰 생성
    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());
        }
    }

    // Cookie에 들었던 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

package com.sparta.springauth.auth;

import com.sparta.springauth.entity.UserRoleEnum;
import com.sparta.springauth.jwt.JwtUtil;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
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";

    private final JwtUtil jwtUtil;

    public AuthController(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @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;
    }

    @GetMapping("/create-session")
    public String createSession(HttpServletRequest req) {
        // 세션이 존재할 경우 세션 반환, 없을 경우 새로운 세션을 생성한 후 반환
        HttpSession session = req.getSession(true);

        // 세션에 저장될 정보 Name - Value 를 추가합니다.
        session.setAttribute(AUTHORIZATION_HEADER, "Robbie Auth");

        return "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-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;
    }

    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());
        }
    }
}

 

 

금요일까지 알아볼 것

OSI 7계층

WS VS WAS

프록시

Spring의 3대 원칙

'TIL > Spring' 카테고리의 다른 글

23.06.21  (0) 2023.06.21
23.06.20  (0) 2023.06.20
23.06.15  (0) 2023.06.15
23.06.14  (0) 2023.06.14
23.06.13  (0) 2023.06.13