728x90

나는 개발을 할 때 MacOS를 사용해왔었고, iterm2을 설치하고 oh my zsh로 커스텀하여 편리한 기능을 많이 활용할 수 있었다.

하지만 회사의 보안 정책으로 본인의 전자장비를 반입할 수 없었고 설상가상으로 회사 컴퓨터는 맥북이 아닌 윈도우11을 사용중이었다.

 

그렇다고 명령프롬프트나 git bash 등을 사용하기에는 기존에 사용하던 편리한 기능들을 사용할 수 없었고

능률이 떨어질것이라고 판단하여 윈도우에서 터미널을 사용할 수 있는 방법에 대해 연구하여 적용시켜보자 마음먹게 되었다.

 

검색 결과, WSL을 사용하면 윈도우에서 리눅스를 일종의 가상환경처럼 사용할 수 있다는 것을 알게되었고, 이를 적용시키면 윈도우에서도 충분히 기존 작업환경과 유사한 개발환경을 세팅할 수 있겠다고 판단하였다.

 

 

✅ WSL 설치

1. WSL 활성화

이 방법은 Window11 이상 or Windows 10 특정 버전 이상에서 권장된다.

PowerShell 이나 cmd 창을 관리자 권한으로 실행하여 아래 명령어를 입력한다.

 

wsl --install

 

✅ 우분투 설치 

우분투 설치가 자동으로 되는 경우도 있지만, 나의 경우는 직접 설치를 해줘야 했다.

아래 명령어를 입력하여 우분투 설치를 진행한다.

 

wsl.exe --install --no-distribution

 

이후 변경사항을 적용하기 위해 컴퓨터를 종료후 다시 실행한다. (이 과정에서 윈도우 11로 업데이트도 동시에 진행해주었다.)

재부팅 이후, 아래 명령어를 입력하여 우분투를 최초 실행하게 되면 우분투 사용자 이름과 비밀번호를 설정하게 된다.

본인이 원하는대로 설정하면 초기 설정이 완료되고, 우분투 터미널을 사용할 수 있게 된다.

 

여기까지 완료되었다면 터미널 디자인을 커스텀할 차례이다.

커스텀은 oh my zsh를 통해 진행할 것이다. 아래 동영상을 참고하여 자신이 원하는대로 커스텀을 진행한다.

 

 

✅ 1단계: zsh 설치 및 기본 셸로 설정

 
 
apt 업데이트를 진행하기 위해 명령어를 입력한다.
 
sudo apt update && sudo apt upgrade -y
 
sudo 설정이 되어있으므로 앞서 설정한 자신의 비밀번호를 입력하고 Enter를 눌러 업데이트를 진행한다.

 

oh my zsh를 사용하기 위해 zsh를 설치하고, 기본 셀로 지정한다.

 

sudo apt install -y zsh

zsh --version  # 설치 확인

 

 

정상적으로 설치가 되었다면 아래 명령어를 입력하여 기본 셸로 zsh을 설정한다.

 

chsh -s $(which zsh)

 

이후 zsh를 입력하면 아래와 같이 안내문자가 나오는데 2번을 눌러주면 된다.

 

 2번 누르기

 

✅ Homebrew 설치

 
아래 명령어를 입력하여 homebrew 설치를 진행한다.
 
 
 

 

설치가 완료되었다면, 설치된 homebrew에 대한 환경변수를 zsh에 등록해야한다. 아래 명령어를 입력하여 환경 변수를 등록한다.

 

echo 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"

 

잘 등록되었다면 brew --version을 입력하였을 때, 아래와 같이 버전이 출력되어야한다.

 

✅ oh-my-zsh 설치

이어서 oh-my-zsh를 설치해보자.
명령어를 입력하고, Y와 비밀번호를 누르면 oh-my-zsh 의 문구가 출력되고 기본 설정이 oh-my-zsh로 변경된다.

 

sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"

 

 

 

초기설정이 완료되었으니 터미널을 꾸밀 차례이다.

 

나는 아래 블로그를 참고하였다.

https://kdohyeon.tistory.com/122

 

터미널 꾸미기: Oh-my-zsh + iTerm2

이번 시간에는 맥북을 처음 세팅할 때 터미널 환경을 커스텀하게 설정하는 방법에 대해서 정리합니다. iTerm2 설치 MacOS 에서 기본으로 제공하는 터미널은 다소 아쉬운 부분이 있어 보통 iTerm2 어

kdohyeon.tistory.com

 

윈도우에서는 iTerm2를 통한 터미널 꾸미기가 불가능하여 다소 단조로운 형태의 터미널이 완성되었다.

 

✅ 커스텀 명령어 추가

WSL은 리눅스 터미널을 사용할 수 있기 때문에, 맥에서 사용하던 대부분의 명령어를 사용할 수 있지만 'open .' 처럼 mac에서 제공하는 명령어는 사용이 불가능하다.

WSL의 기본 쉘인 Bash 또는 Zsh 설정 파일(~/.bashrc 또는 ~/.zshrc)에 명령어를 추가함으로서 커스텀 명령어를 추가하고 사용할 수 있다.

 

open . 은 현재 디렉토리를 파일 탐색기로 실행시키는 명령어이다.

아래와 같은 과정을 통해 커스텀 명령어를 생성할 수 있다.

 

1. zsh 설정 파일 실행

nano ~/.zshrc

 

2. 명령어 추가

alias open="explorer.exe"

 

3. 설정 반영

source ~/.zshrc

 

 

이후 원하는 디렉토리에서 테스트하여 원하는대로 동작하는지 확인한다.

이밖에 필요한 명령어가 있다면 zsh 설정파일 문법에 대해 공부하거나, GPT 등에 명령구문 작성을 요청하여 옮겨 적으면 된다.

 

✅ 빠른 실행 도구 추가

마지막으로 완성한 커스텀 터미널을 기존 명령 프롬프트처럼 빠른 실행 도구를 통해 접속해보자.

윈도우 + R 키를 누르면 실행 도구가 켜지는데, 여기서 cmd를 입력하면 명령 프롬프트가 실행된다는 것은 많은 사람들이 알고 있는 사실이다.

 

이 실행도구에 커스텀 터미널을 등록시켜 간편하게 사용하는 것이 최종 목표이다.

 

'실행' 도구는 윈도우 환경에 따라 다르나, 주로 아래 경로에 있는 프로그램의 이름을 입력할 경우 실행되는 구조를 갖고 있다.

 

C:\Windows\System32

%USERPROFILE%\AppData\Local\Microsoft\WindowsApps

 

나의 경우 System32 폴더에서 cmd.exe 프로그램을 찾을 수 있었다.(못 찾겠다면 우측 상단의 검색 도구를 사용하여 cmd를 검색해보자)

 

이 디렉토리에 원하는 프로그램을 저장시켜두면, cmd나 notepad 처럼 원하는 프로그램을 명령어를 통해 실행시킬 수 있다.

기존의 커스텀 터미널은 파워쉘을 열고,wsl 명령어를 입력하여 실행시켜야되는 번거로움이 있으므로 커스텀 프로그램을 빠른 실행 명령어에 추가하도록 하겠다.

 

먼저 커스텀 터미널에 접속할 수 있는 batch 프로그램을 만든다.

 

아래 내용을 메모장에 붙여넣고, 원하는 이름.bat 로 간단한 배치 프로그램을 생성한다.

 

@echo off
rem 사용자 이름 가져오기
set "USERNAME=%USERNAME%"
rem WSL에서 데스크탑으로 이동
wsl.exe ~ -e bash -c "cd /mnt/c/Users/%USERNAME%/Desktop && exec bash"

 

 

 

작성한 배치 프로그램을 실행하여 wsl 터미널이 실행되는지 확인하고, 앞서 확인한 경로에 붙여 넣는다.(관리자 권한 필요)

 

이 과정까지 수행하였다면 파일 이름(term)을 입력하여 cmd나 notepad 처럼 빠르게 프로그램을 실행할 수 있다.

 

 

728x90

 

VPC 접속 후 기본 VPC 및 서브넷 삭제

 

 

새로운 VPC 생성 - VPC 등 선택

 

서브넷 CIDR 블록 사용자 지정 클릭 - 세부 정보 수정, NAT 게이트웨이, VPC 엔드포인트 없음으로 설정

 

VPC 생성 후 - 기본 라우팅 테이블로 이동

 

라우팅 테이블 - 라우팅 편집 선택

 

라우팅 추가 - 인터넷 게이트웨이 deploy-igw 선택

 

서브넷에 아래와 같이 연결되었는지 확인

 

보안 그룹 - 보안 그룹 생성 선택

 

ec2용 보안 그룹 생성 

보안 그룹 이름과 설명에는 나중에 알아보기 쉽도록 적절한 이름 설정, VPC는 앞서 만든 deploy-vpc 선택

 

인바운드 규칙 추가

HTTP, SSH, 사용자 지정 TCP(서버 포트 설정용)

 

DB용 보안 그룹 생성

DB용으로 생성했다는 사실을 기억하기 쉬운 이름 설정 후, EC2 보안그룹과 동일하게 VPC 선택

인바운드 규칙에 MYSQL 관련 유형 선택 후 생성

 

 

RDS -  서브넷 그룹 - DB 서브넷 그룹 생성 선택

 

세부 정보 입력 후 VPC 에 앞서 생성한 VPC 선택

 

서브넷 추가 - 2a, 2b 가용 영역 선택

'Backend' 카테고리의 다른 글

[SpringBoot] Spring Securtiy JWT(2)  (1) 2024.06.22
[SpringBoot] Spring Securtiy JWT(1)  (0) 2024.06.18
[SpringBoot] Spring Securtiy (2)  (1) 2024.06.14
[SpringBoot] Spring Securtiy (1)  (0) 2024.06.11
[Spring] Spring Initializr Naming Rule  (0) 2024.06.08
728x90

해당 포스트는 유튜브 '개발자 유미' 님의 스프링 시큐리티 JWT 강의 영상을 바탕으로 작성하였습니다.

https://www.youtube.com/playlist?list=PLJkjrxxiBSFCcOjy0AAVGNtIa08VLk1EJ

 

스프링 시큐리티 JWT

JWT 방식 인증을 위한 스프링 시큐리티 구현 방법 (Spring Security JWT)

www.youtube.com

 

7. JWT 발급 및 검증 클래스

JWT는 사용자 정보를 내포하는 토큰으로 인증된 사용자인지 식별하는 용도로 사용된다. 

로그인에 성공하게 되면 서버는 클라이언트에게 JWT를 발급하고, 클라이언트는 서버에 요청을 할 때마다 JWT를 함께 전송함으로서 서버는 요청을 하는 사용자가 인증된 사용자인지, 해당 요청에 대한 접근 권한이 있는지 등을 검토할 수 있게 된다.

 

이때 서버는 클라이언트가 전송한 JWT가 신뢰가능한지 검증해야 한다.(실제로 서버에서 발급해준 토큰인지, 유효기간이 지나지는 않았는지 등)

따라서 JWT를 발급하고 검증하는 JWTUtil 이라는 클래스를 생성하여 JWT를 발급하고 검증하는 메소드를 작성하도록 한다.

 

그전에 JWT가 무엇인지 이해를 하는 것이 중요하다. 

공식문서와 도움이 될만한 유튜브 영상을 첨부하니 참고하면 좋을 것 같다.

 

https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

 

https://www.youtube.com/watch?v=XXseiON9CV0

 

JWT는 . 을 기준으로 Header, PAYLOAD, VERIFY SIGNATURE 로 분리된다.

 

HEADER

- 해당 토큰이 JWT 임을 명시한다.

- 해당 토큰을 암호화할때 사용한 암호화 알고리즘을 명시한다. ( "alg" : "HS256")

 

PAYLOAD

- 실질적인 정보가 담기는 부분이다.

- 예) 사용자 이름, 나이, 권한, 등

 

VERIFY SIGNATURE

JWT를 발행한 서버, 혹은 시크릿 키를 보유중인 서버에서만 JWT를 사용할 수 있도록 Header와 Payload, 마지막으로 시크릿 키를  Header에서 명시한 암호화 알고리즘을 바탕으로 암호화한 값

암호화 알고리즘(BASE64(Header) + BASE64(Payload) + 시크릿 키)

 

JWT는 내부 정보를 BASE64방식으로 인코딩 하기 때문에 서버가 아니더라도 외부에서 쉽게 디코딩 가능하다.

따라서 외부에서 열람해도 되는 정보(사용자 이름, 권한, 등)만을 담아야하며, 토큰의 발급처를 확인하기 위해서 사용한다.

 

 

JWT 암호화 방식

 

해당 프로젝트에서는 JWT 암호화를 위해 양방향 암호화 방식, 그 중 대칭키 방식으로 진행할 것이다.

 

- 대칭키 방식 : 동일한 키를 사용하여 암호화와 복호화를 사용한다. 예) HS256

- 비대칭키 방식 : 암호화와 복호화에 사용되는 키가 서로 다르다.

 

시크릿 키 생성을 위해 터미널에서 아래 명령어를 입력한다.

 

생성된 시크릿 키는 application.properties에 임의의 변수명을 작성하여 저장한다.

spring.application.name=new-jwt-practice

# DATABASE Connection
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/jwt-practice
spring.datasource.username=root
spring.datasource.password=1234

# JPA
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

logging.level.org.springframework.security=DEBUG

# Secret Key 설정
# 아래 명령어를 통해 무작위 시크릿 키를 생성할 수 있다.
# openssl rand -hex 32
# spring.jpa.secret은 임의의 변수명에 해당한다. 단 jpa에서 제공하는 변수명과 겹치면 안된다.
spring.jpa.secret = 26b2386c5f8a815df8676200e24cbbd03af84150c9b2bd645db9ba4df126b704

 

이제 시크릿키를 사용하여 JWT를 발급하고,JWT가 유효한지 검증하는 등의 역할을 수행할 유틸 클래스를 작성해야한다.

 

JwtUtil.java

package com.example.new_jwt_practice.jwt.util;

import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Date;

// JWTUtil 0.12.3
// JWT를 검증할 메소드, 생성할 메소드를 구현
// username, role, 생성일, 만료일 확인용 메소드
// => 생성 : 토큰의 Payload에 username, role, 생성일, 만료일을 저장
@Component
public class JwtUtil {
    private SecretKey secretKey;

    // application.properties에 저장된 시크릿키를 대상으로 객체 키 생성
    public JwtUtil(@Value("${spring.jpa.secret}") String secret) {
        secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
    }

    // 토큰을 통해 사용자 이름 추출
    public String getUsername(String token) {

        return Jwts.parser()
                .verifyWith(secretKey) // 시크릿 키를 사용하여 복호화한다.
                .build() // 복호화에 사용할 Jwts Parser 생성
                // 생성된 Parser를 사용하여 token으로 부터 정보 추출
                .parseSignedClaims(token)
                .getPayload().get("username", String.class);
    }

    // 토큰으로부터 사용자 권한 추출
    public String getRole(String token) {

        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .get("role", String.class);
    }

    // 토큰이 만료되었는지 확인, 오늘 날짜와 토큰에 저장된 만료일자를 비교한다.
    // 만료되었다면 true
    public Boolean isExpired(String token) {

        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getExpiration()
                .before(new Date());
    }

    // 토큰 생성 메소드
    // 사용자 이름, 권한, 토큰 유지 시간을 매개변수로 전달받는다.
    public String createJwt(String username, String role, Long expiredMs) {

        return Jwts.builder()
                .claim("username", username)
                .claim("role", role)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(secretKey)
                .compact();
    }


}

 

@Value를 사용하여 application.properties 에 작성해둔 시크릿키를 불러와 Secret 클래스 sercetKey에 값을 할당한다.

위 과정은 @Component에 의해 컴포넌트 스캔시 생성자가 호출되며 진행된다.

 

위 클래스는 createJwt()를 사용하여 사용자의 username, role 에 대한 정보를 JWT에 담아 반환하며 매개변수로 받은 expiredMs 시간 만큼을 JWT의 유효시간으로 설정한다.

이후 isExpired()를 통해 토큰의 유효기간을 검증하며, getUsername(), getRole() 을 사용하여 JWT에 담겨진 사용자 정보를 얻어온다.

 

JWT의 Payload에 정보가 담기게 되며, 정보를 담는 기본 단위는 claim이다. 따라서 .claim() 을 사용하여 사용자가 주입하고 싶은 정보를 JWT의 Payload에 저장할 수 있으며, parseSignedClaims(token) 을 사용하여 해당 JWT에 대한 클레임 정보들을 파싱할 수 있다.

 

8. 로그인 성공시 JWT 발급 

앞서서 사용자가 로그인에 성공하였을 때 서버에서 JWT를 발급해서 반환해주고, 이후 사용자는 JWT를 매 요청마다 Header에 첨부하게 된다고 하였다. 

그렇다면 이제부터 사용자가 로그인에 성공하였을 때 JWT를 발급하는 코드를 작성해보자.

 

/login요청은 UsernamePasswordAuthenticationFilter를 대체하는 LoginFilter에 의해 수행되며, 해당 클래스를 상속받고 있기 때문에 successfulAuthentication() 과 unsuccessfulAuthentication()을 override하고 있는 상태이다.

위 두 함수들은 로그인필터에 의해 사용자가 로그인에 성공하였을 때와 실패하였을 때 실행되며 이 함수들에 JWT를 발급하는 코드와 실패시 오류코드를 반환하는 코드를 작성하면 된다.

 

LoginFilter.java

package com.example.new_jwt_practice.jwt.filter;

import com.example.new_jwt_practice.jwt.dto.CustomUserDetails;
import com.example.new_jwt_practice.jwt.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.io.IOException;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.stream.Collectors;

// 폼 로그인 방식의 경우 스프링 시큐리티 필터들 중 UsernamePasswordAuthenticationFilter에 의해 수행된다.
// 해당 필터는 AuthenticationManager를 사용하여 사용자의 인증절차를 수행하는데
// 이때 UserDetailsService가 제공하는 UserDetails를 사용한다.
// 해당 프로젝트의 경우 폼 로그인 방식을 disable하였으므로
// 사용자의 로그인 요청을 수행하기 위한 UsernamePasswordAuthenticationFilter를 커스텀하여 로그인 요청을 수행한다.
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager; // 인증 절차를 수행할 때 사용

    // 사용자 로그인 성공시 JWT 토큰을 발급해주기 위해 JwtUtil을 주입받는다.
    private final JwtUtil jwtUtil;

    @Override
    public Authentication attemptAuthentication(
            HttpServletRequest request,
            HttpServletResponse response) throws AuthenticationException {

        // 클라이언트 요청에서 username, passowrd 추출
        String username = obtainUsername(request);
        String password = obtainPassword(request);

        // 스프링 시큐리티에서 username과 password를 검증하기 위해서는 토큰에 담아야한다.
        // username, password, authorities
        UsernamePasswordAuthenticationToken authToken
                = new UsernamePasswordAuthenticationToken(username, password,null);

        // 토큰 정보를 바탕으로 검증 수행 및 결과 반환
        // 검증은 AuthenticationManager가 UserDetails를 바탕으로 수행한다.
        // DB의 정보를 바탕으로 UserDetailsService에 의해 UserDetails가 생성되면 AuthenticationManager가 이를 토대로 검증한다.
        Authentication authentication = authenticationManager.authenticate(authToken);

        return authentication;
    }

    // 로그인 성공시 실행
    // 로그인 성공시 사용자 정보를 담고 있는 JWT 토큰을 발급 해준다.
    @Override
    protected void successfulAuthentication(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain, Authentication authResult) throws IOException, ServletException {

        // 로그인 성공시, 사용자에게 토큰 발급
        CustomUserDetails customUserDetails = (CustomUserDetails) authResult.getPrincipal();
        // 사용자 이름 추출
        String username = customUserDetails.getUsername();
        // 사용자 권한 추출
        Collection<? extends GrantedAuthority> authorities = customUserDetails.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        String role = auth.getAuthority();

        String token = jwtUtil.createJwt(username, role, 60 * 60 * 10 * 10L);

        // HTTP 반환 헤더에 "Authorization" 키 값으로 토큰 반환
        response.addHeader("Authorization","Bearer " + token);

    }

    // 로그인 실패시 실행
    @Override
    protected void unsuccessfulAuthentication(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException failed) throws IOException, ServletException {
        // 실패 코드 반환
        response.setStatus(401);
    }
}

 

앞서 작성한 JwtUtil 클래스를 사용하기 위해 JwtUtil 클래스를 주입받았다.

로그인에 성공할경우 Authentication 객체로부터 사용자의 이름과 권한을 추출하여 JwtUtil의 createJwt() 를 통해 JWT를 발급받은후 response의 헤더부분에 "Authorization"이라는 키로 "Bearer {JWT토큰값}" 의 형태로 JWT를 삽입한다.

로그인에 실패할 경우 401코드를 반환하도록 설정한다.

 

포스트맨을 통해 테스트를 진행해보면 로그인 성공시 리턴 Response의 Header에 JWT가 반환되는 것을 확인할 수 있다.

 

9. JWT 검증 필터

사용자는 모든 요청에 대해서 앞으로 Header에 JWT를 넘겨줌으로서 서버는 사용자의 인증,인가를 진행하게 된다고 하였다.

그렇다면 사용자가 첨부한 JWT를 검증하는 과정이 필요할 것이다.

JWTFilter를 작성하여 위 과정을 진행한다.

package com.example.new_jwt_practice.jwt.filter;

import com.example.new_jwt_practice.entity.Member;
import com.example.new_jwt_practice.jwt.dto.CustomUserDetails;
import com.example.new_jwt_practice.jwt.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

// 사용자가 JWT를 첨부하여 요청시, JWT를 바탕으로 사용자 정보를 확인하여 인가처리를 수행한다.
// 이때 JWT를 검증(올바르게 생성된 토큰인지, 올바른 권한을 보유중인지, 등)하고, 한번만 사용할 임시 세션을 만들어 요청을 수행한다.
// STATELESS하게 관리되므로 한번의 요청이후 해당 토큰은 사라진다.
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {

    // JWT 검증용
    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        // 토큰 추출
        String authorization = request.getHeader("Authorization");
        // 토큰 유효성 검증
        // - Bearer 접두사가 있는지, 토큰이 실제로 존재하는지, 등
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            System.out.println("token null");
            filterChain.doFilter(request, response);
            return;
        }
        // Bearer 접두사 제거
        String token = authorization.split(" ")[1];

        // 토큰 소멸시간 검증
        if (jwtUtil.isExpired(token)) {
            System.out.println("token expired");
            filterChain.doFilter(request,response);
            return;
        }

        // 토큰에서 username, role 획득
        String username = jwtUtil.getUsername(token);
        String role = jwtUtil.getRole(token);

        // 사용자 객체 생성 및 값 주입
        Member member = Member.builder()
                .username(username)
                .role(role)
                .build();

        // UserDetails에 회원 정보 객체 담기
        CustomUserDetails customUserDetails = new CustomUserDetails(member);

        // 스프링 시큐리티 인증 토큰 생성 => 해당 토큰을 바탕으로 AuthenticationManager에서 사용자 권한 검증 수행
        Authentication authToken
                = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
        // 세션에 사용자 등록
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request,response);
    }
}

 

JWTFilter는 OncePerRequestFilter를 상속받음으로서 Security Filter로 등록가능하다.

OncePerRequestFilter의 doFilterInternal을 override 하여 토큰의 유효성에 대해 검증하며 매개변수로 전달받은 HttpServletRequest에서 "Authorization" 헤더를 추출하여 토큰을 추출한뒤 JwtUitl을 주입받아, JwtUtil에 구현해둔 함수를 사용하여 토큰에 대한 유효성을 검증한다.

만약 토큰에 문제가 발생할 경우 filterChain.doFilter()를 호출하여 필터체인의 다른 필터들로 요청을 넘기고 함수를 종료한다.

만약 토큰에 문제가 없다면 jwtUtil을 사용하여 토큰의 클레임에서 사용자 정보를 추출하고 UserDetails 객체를 만들어 AuthenticationManager에서 사용할수 있도록 스프링 시큐리티 인증 토큰을 생성하여 반환한다.

 

기본적으로 JWT를 사용한 인가처리 방식의 경우 사용자 정보를 세션에 저장하지 않는 STATELESS 방식이지만 사용자의 요청을 처리하기 위해서는 결국 하나의 세션이 필요하다. 그렇기 때문에 JWT정보를 바탕으로 사용자의 요청이 끝날때 까지 유효한 임시 세션을 만들어 사용자 정보를 등록시켜 사용하게 되는데, 이를 위해 SecruityContextHolder에 setAuthentication을 호출하여 사용자 정보를 세션으로 만들어 등록한다. 추후 SecurityContextHolder를 통해 등록해둔 사용자 정보를 추출하여 사용할 수 있게 된다.

해당 세션은 사용자 요청이 종료되는 순간 삭제되어 STATELESS한 상태가 된다.

 

추가로 작성한 필터를 등록시키는 과정이 필요한데, SecurityConfig의 addFilterBefore()를 통해 로그인 필터 이전에 위치시키도록 한다.

 

SecurityConfig.class

package com.example.new_jwt_practice.jwt.config;

import com.example.new_jwt_practice.jwt.filter.JWTFilter;
import com.example.new_jwt_practice.jwt.filter.LoginFilter;
import com.example.new_jwt_practice.jwt.util.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
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.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;

import java.util.Collections;

@Configuration
@EnableWebSecurity // 시큐리티를 위한 구성파일임을 알림
@RequiredArgsConstructor
public class SecurityConfig {

    // AuthenticationManager의 AuthenticationConfiguration에서 사용하기 위함
    private final AuthenticationConfiguration authenticationConfiguration;

    // LoginFilter의 RequireArgsConstructor에 의해 생성자 주입이 발생,
    // 따라서 SecurityConfig에서 LoginFilter 생성자 호출시 JwtUtil 주입 필요
    private final JwtUtil jwtUtil;

    // 사용자 비밀번호를 관리하기 위한 암호화 클래스
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // 커스텀한 UsernamePasswordAuthentication Filter에서 사용하기 위해 Authentication 빈 등록
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        // csrf disable
        http
                .csrf(auth -> auth.disable()); // JWT 방식은 세션을 Stateless로 관리하기 때문에 csrf 공격에 비교적 안전하다.

        // JWT 방식을 사용할 것이므로 이외의 방식은 모두 비활성화 한다.
        // Form 로그인 방식 disable
        http
                .formLogin(auth -> auth.disable());

        // http basic 인증 방식 disable
        http
                .httpBasic(auth -> auth.disable());

        // 경로별 인가 설정
        http
                .authorizeHttpRequests(auth -> auth
                        // login, root, join 경로의 요청에 대해서는 모두 허용
                        .requestMatchers("/login", "/", "/join").permitAll()
                        // admin 경로 요청에 대해서는 ADMIN 권한을 가진 경우에만 허용
                        .requestMatchers("/admin").hasRole("ADMIN")
                        // 이외의 요청에 대해서는 인증된 사용자만 허용
                        .anyRequest().authenticated()
                );

        // 커스텀한 UsernamePasswordAuthenticationFilter를 사용하도록 알림
        // addFilterAt : 해당 필터를 대체한다.(그 자리를 대체한다.)
        // addFilterAt의 2번째 인자는 어떤 위치를 대체할것인지 해당 필터를 작성한다.(이 경우 Username.. Filter)
        http
                .addFilterAt(new LoginFilter
                                (authenticationManager(authenticationConfiguration),jwtUtil),
                        UsernamePasswordAuthenticationFilter.class);

        // 로그인 필터 앞에 JWT 검증용 필터 추가
        http
                .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);

        // 가장 중요
        // JWT 방식에서는 세션을 Stateless 상태로 관리하게 됨(서버 세션에 저장하지 않음)
        // 따라서 세션을 STATELESS 상태로 설정 해주어야 한다.
        http
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
}

 

 

10. 세션 정보

앞서 설명한대로, JWT를 바탕으로 임시 세션을 만들어서 사용자 요청이 진행되는 동안 세션에 사용자 정보를 보관한다.

MainController에서 사용자 정보를 추출하여 반환하는 코드를 작성해보자.

 

MainController.java

package com.example.new_jwt_practice.controller;

import lombok.Builder;
import lombok.Data;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Collection;
import java.util.Iterator;

@RestController
public class MainController {

    @Data
    @Builder
    public static class MemberInfo {
        private String username; // 사용자 이름
        private String role; // 권한
    }


    @GetMapping("/")
    public MemberInfo mainP() {
        // JWT는 STATELESS한 방식으로 구동되긴 하지만 일시적으로 세션을 만들어 사용한다.
        // 따라서 세션을 통해 JWT에 내포된 사용자 정보를 추출할 수 있다.
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String name = authentication.getName();
        // 권한 추출
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        String authority = auth.getAuthority();

        MemberInfo memberInfo = MemberInfo.builder()
                .username(name)
                .role(authority)
                .build();

        return memberInfo;
    }
}

 

SecurityContextHolder.getContext() 에서 getAuthentication()을 호출하여 Authentication 객체를 얻어와 사용자의 이름과 권한을 추출한다. 이 API 에서는 사용자의 이름과 권한을 반환하도록 작성해보았다.

 

포스트맨을 통해 직접 테스트 해보면 Header에 첨부한 토큰 내용을 바탕으로 사용자 정보를 추출하여 반환하는 것을 확인할 수 있다.

 

11. CORS 설정

서버 API를 호출할 때, 웹 브라우저의 경우 교차 출저 리소스 정책을 준수하지 않을 경우 CORS 문제가 발생한다.

https://ko.wikipedia.org/wiki/%EA%B5%90%EC%B0%A8_%EC%B6%9C%EC%B2%98_%EB%A6%AC%EC%86%8C%EC%8A%A4_%EA%B3%B5%EC%9C%A0

 

교차 출처 리소스 공유 - 위키백과, 우리 모두의 백과사전

위키백과, 우리 모두의 백과사전. 교차 출처 리소스 공유(Cross-origin resource sharing, CORS), 교차 출처 자원 공유는 웹 페이지 상의 제한된 리소스를 최초 자원이 서비스된 도메인 밖의 다른 도메인으

ko.wikipedia.org

 

이 문제를 방지하기 위해 서버에서 특정 출저에 대해 허용해주도록 코드를 작성해주어야 한다.

우리의 프로젝트의 경우 스프링 시큐리티 필터를 거치고, Controller 단을 거쳐서 들어가기 때문에 해당 출저에 대해 아래 코드들을 작성해주어야 한다.

 

CorsMvcConfig.java

@Configuration
public class CorsMvcConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry corsRegistry) {

        corsRegistry.addMapping("/**")
                .allowedOrigins("http://localhost:3000");
    }
}

 

SecurityConfig.java

package com.example.new_jwt_practice.jwt.config;

import com.example.new_jwt_practice.jwt.filter.JWTFilter;
import com.example.new_jwt_practice.jwt.filter.LoginFilter;
import com.example.new_jwt_practice.jwt.util.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
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.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;

import java.util.Collections;

@Configuration
@EnableWebSecurity // 시큐리티를 위한 구성파일임을 알림
@RequiredArgsConstructor
public class SecurityConfig {

    // AuthenticationManager의 AuthenticationConfiguration에서 사용하기 위함
    private final AuthenticationConfiguration authenticationConfiguration;

    // LoginFilter의 RequireArgsConstructor에 의해 생성자 주입이 발생,
    // 따라서 SecurityConfig에서 LoginFilter 생성자 호출시 JwtUtil 주입 필요
    private final JwtUtil jwtUtil;

    // 사용자 비밀번호를 관리하기 위한 암호화 클래스
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // 커스텀한 UsernamePasswordAuthentication Filter에서 사용하기 위해 Authentication 빈 등록
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        // CORS 동일출저 정책 방지용 설정 작성
        http
                .cors((corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {

                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {

                        CorsConfiguration configuration = new CorsConfiguration();
                        // 로컬호스트 3000번 포트의 요청에 대해서
                        configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
                        // GET, POST, 등 모든 요청 허용
                        configuration.setAllowedMethods(Collections.singletonList("*"));
                        configuration.setAllowCredentials(true);
                        configuration.setAllowedHeaders(Collections.singletonList("*"));
                        configuration.setMaxAge(3600L);
                        // JWT가 Authorization 헤더에 담기므로
                        configuration.setExposedHeaders(Collections.singletonList("Authorization"));

                        return configuration;
                    }
                })));

        // csrf disable
        http
                .csrf(auth -> auth.disable()); // JWT 방식은 세션을 Stateless로 관리하기 때문에 csrf 공격에 비교적 안전하다.

        // JWT 방식을 사용할 것이므로 이외의 방식은 모두 비활성화 한다.
        // Form 로그인 방식 disable
        http
                .formLogin(auth -> auth.disable());

        // http basic 인증 방식 disable
        http
                .httpBasic(auth -> auth.disable());

        // 경로별 인가 설정
        http
                .authorizeHttpRequests(auth -> auth
                        // login, root, join 경로의 요청에 대해서는 모두 허용
                        .requestMatchers("/login", "/", "/join").permitAll()
                        // admin 경로 요청에 대해서는 ADMIN 권한을 가진 경우에만 허용
                        .requestMatchers("/admin").hasRole("ADMIN")
                        // 이외의 요청에 대해서는 인증된 사용자만 허용
                        .anyRequest().authenticated()
                );

        // 커스텀한 UsernamePasswordAuthenticationFilter를 사용하도록 알림
        // addFilterAt : 해당 필터를 대체한다.(그 자리를 대체한다.)
        // addFilterAt의 2번째 인자는 어떤 위치를 대체할것인지 해당 필터를 작성한다.(이 경우 Username.. Filter)
        http
                .addFilterAt(new LoginFilter
                                (authenticationManager(authenticationConfiguration),jwtUtil),
                        UsernamePasswordAuthenticationFilter.class);

        // 로그인 필터 앞에 JWT 검증용 필터 추가
        http
                .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);

        // 가장 중요
        // JWT 방식에서는 세션을 Stateless 상태로 관리하게 됨(서버 세션에 저장하지 않음)
        // 따라서 세션을 STATELESS 상태로 설정 해주어야 한다.
        http
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
}

 

이렇게 작성하게 되면 CORS 문제를 방지할 수 있다.

'Backend' 카테고리의 다른 글

[AWS] 네트워크 구축  (0) 2024.12.08
[SpringBoot] Spring Securtiy JWT(1)  (0) 2024.06.18
[SpringBoot] Spring Securtiy (2)  (1) 2024.06.14
[SpringBoot] Spring Securtiy (1)  (0) 2024.06.11
[Spring] Spring Initializr Naming Rule  (0) 2024.06.08
728x90

해당 포스트는 유튜브 '개발자 유미' 님의 스프링 시큐리티 JWT 강의 영상을 바탕으로 작성하였습니다.

https://www.youtube.com/playlist?list=PLJkjrxxiBSFCcOjy0AAVGNtIa08VLk1EJ

 

스프링 시큐리티 JWT

JWT 방식 인증을 위한 스프링 시큐리티 구현 방법 (Spring Security JWT)

www.youtube.com

 

스프링 시큐리티 프레임워크를 사용하여 사용자의 인증/인가를 수행하는 방법에는 2가지가 있다.

 

1. Session 방식

사용자의 정보를 서버의 세션에 저장하고, 클라이언트에 세션 ID를 반환하는 방식이다. 인증된 사용자 정보를 모두 서버에서 관리하기 때문에 STATEFUL 한 방식으로 동작하며 사용자의 요청이 들어올 때 마다 클라이언트가 함께 첨부한 세션 ID를 바탕으로 사용자 정보를 조회하여 인증,인가를 수행한다. 폼 로그인, HTTP Basic 로그인 방식 등에서 적합하다. 장점으로는 세션 관리가 서버에서 이루어지기 때문에 클라이언트 측의 부담이 적고, 단점으로는 서버의 부하가 증가할 수 있다는 점이다.

 

2. JWT 방식

 

RESTful API 서버에서 주로 채택하는 방식이다. 사용자가 로그인, 또는 회원가입을 하면 사용자의 정보를 담고있는 JWT(JSON Web Token)을 사용자에게 반환한다. 사용자는 이후 모든 요청마다 JWT를 첨부하게 되고 서버는 이 JWT를 복호화하여 사용자 정보를 추출함으로서 인증, 인가 절차를 수행한다. 이 방식은 STATELESS한 방식으로, 서버가 세션 상태를 유지할 필요가 없으므로 확장성이 높고, 특히 분산 시스템에서 유리하다. JWT는 클라이언트 측에서 관리되기 때문에 서버의 부하를 줄일 수 있지만, 토큰 탈취 등의 보안 문제에 취약할 수 있다.

 

이전에 작성한 Spring Security (1),(2) 게시글에서 MVC 서버에서 스프링 시큐리티 Session 방식을 사용하여 사용자 인증,인가 를 수행해보았다. 이번 게시글에서는 REST API 서버에서 JWT를 사용한 사용자 인증,인가 방식에 대해 알아보자. 

앞선 게시글과 중복되는 설명의 경우 작성하지 않을 예정이며, 스프링 시큐리티에 대해 이해하고 있어야 JWT 방식에 대해 이해하기 편하므로 먼저 앞선 게시글들을 읽어보는 것을 추천한다.

 

https://76codefactory.tistory.com/70

 

[SpringBoot] Spring Securtiy (1)

해당 포스트는 유튜브 '개발자 유미' 님의 스프링 시큐리티 강의 영상을 바탕으로 작성하였습니다.https://www.youtube.com/playlist?list=PLJkjrxxiBSFCKD9TRKDYn7IE96K2u3C3U 스프링 시큐리티스프링 시큐리티 : Spri

76codefactory.tistory.com

https://76codefactory.tistory.com/71

 

[SpringBoot] Spring Securtiy (2)

해당 포스트는 유튜브 '개발자 유미' 님의 스프링 시큐리티 강의 영상을 바탕으로 작성하였습니다.https://www.youtube.com/playlist?list=PLJkjrxxiBSFCKD9TRKDYn7IE96K2u3C3U 스프링 시큐리티스프링 시큐리티 : Spri

76codefactory.tistory.com

 

 

1. 프로젝트 생성 및 JWT 의존성 추가

버전

- Spring Boot 3.3.0

- Security 6.1.5

- Spring Data JPA - MySQL

- IntellJ Ultimate

 

필수 의존성

- Spring Web

- Lombok

- Spring Security

- Spring Data JPA

- MySQL Driver

 

프로젝트를 생성하고 데이터베이스 관련 설정을 뒤로 미루기 위해 아래와 같이 Spring Data JPA와 MySQL Driver에 대한 의존성을 임시로 주석처리한다.

추가로 JWT를 사용하기 위해 jsonwebtoken 의존성을 주입한다.

dependencies {
	// implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	// runtimeOnly 'com.mysql:mysql-connector-j'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

	// JWT 필수 의존성 설치 0.12.3
	implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
	implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
	implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}

 

2. Config 파일 작성

스프링 시큐리티 프레임워크를 커스터마이징 하기 위해 구성 클래스를 작성한다.

구성 클래스임을 알리기 위해 @Configuration 을, 스프링 시큐리티 구성 클래스임을 알리기 위해 @EnableWebSecurity 를 작성한다.

 

SecurityConfig.java

@Configuration
@EnableWebSecurity // 시큐리티를 위한 구성파일임을 알림
public class SecurityConfig {

    // 사용자 비밀번호를 관리하기 위한 암호화 클래스
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        // csrf disable
        http
                .csrf(auth -> auth.disable()); // JWT 방식은 세션을 Stateless로 관리하기 때문에 csrf 공격에 비교적 안전하다.

        // JWT 방식을 사용할 것이므로 이외의 방식은 모두 비활성화 한다.
        // Form 로그인 방식 disable
        http
                .formLogin(auth -> auth.disable());

        // http basic 인증 방식 disable
        http
                .httpBasic(auth -> auth.disable());

        // 경로별 인가 설정
        http
                .authorizeHttpRequests(auth -> auth
                        // login, root, join 경로의 요청에 대해서는 모두 허용
                        .requestMatchers("/login", "/", "/join").permitAll()
                        // admin 경로 요청에 대해서는 ADMIN 권한을 가진 경우에만 허용
                        .requestMatchers("/admin").hasRole("ADMIN")
                        // 이외의 요청에 대해서는 인증된 사용자만 허용
                        .anyRequest().authenticated()
                );

        // 가장 중요
        // JWT 방식에서는 세션을 Stateless 상태로 관리하게 됨(서버 세션에 저장하지 않음)
        // 따라서 세션을 STATELESS 상태로 설정 해주어야 한다.
        http
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
}

 

JWT 방식은 세션 방식과 달리 사용자 정보를 서버 세션에 저장하지 않는다. 또한 RESTful API 서버는 STATELESS 한 방식으로 동작하기 때문에, CSRF 공격에 비교적 안전하다. 따라서 스프링 프레임워크에 기본적으로 활성화 되어있는 CSRF 설정을 비활성화 해준다.

또한 폼 로그인 방식이 아닌 API 요청을 통해 인증,인가를 수행할 것이기 때문에 formLogin 설정과 httpsBasic 로그인 설정을 비활성화 해준다. 이를 통해 사용자의 요청을 오롯이 API 요청으로 관리할 수 있게된다. authorizeHttpRequest의 requestMAtchers 함수들을 사용하여 각 요청 경로별 인가 설정을 작성하여주었으며, 마지막으로 가장 중요한 설정으로 sessionManagement를 STATELESS 상태로 설정해주어야 한다. 앞서 설명하였듯이 사용자 정보를 서버 세션을 통해 저장하지 않기 때문이다.

 

3. Controller 작성

인증과 인가에 따른 변화를 확인하기 위한 테스트 Controller를 작성한다.

 

AdminController.java

package com.example.new_jwt_practice.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AdminController {

    @GetMapping("/admin")
    public String adminP() {
        return "This is Admin Controller";
    }
}

 

MainController.java

package com.example.new_jwt_practice.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MainController {

    @GetMapping("/")
    public String mainP() {
        return "This is Main Controller";
    }
}

 

모두 GetMapping으로 작성하였으며, SecurityConfig 구성 클래스에 의하여 /admin 요청은 ADMIN 권한을 가진 경우에만 호출이 가능하게 된다.

 

서버를 실행시켰을 때, 스프링 시큐리티 로그인 폼이 보여진다면 여기까지 문제 없이 잘 된 것이다.

 

4. DB 연결 및 Entity 작성

데이터베이스 연결과 사용자 정보를 저장하기 위한 엔티티를 작성해보자.

 

데이터베이스 사용을 위해 주석 처리해두었던 의존성을 다시 해제한다. 

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.mysql:mysql-connector-j'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

	// JWT 필수 의존성 설치 0.12.3
	implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
	implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
	implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}

 

이어서 MySQL에서 데이터베이스를 생성하고 Database 연결을 위해 application.properties를 수정한다.

spring.application.name=new-jwt-practice

# DATABASE Connection
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/jwt-practice
spring.datasource.username=root
spring.datasource.password=1234

# JPA
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

logging.level.org.springframework.security=DEBUG

 

회원 정보를 저장하기 위한 Member 엔티티를 작성한다.

Member.class

@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "MEMBER")
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    private String password;

    // 사용자 권한
    private String role;
}

 

 

5. 회원가입 로직 구현

회원 가입 로직은 Session 방식과 같은 방식으로 진행한다. 

 

회원가입시 사용자가 사용할 username 과 password를 전달받기 위해 dto를 작성한다.

 

JoinDTO.java

import lombok.Data;

@Data
public class JoinDTO {
    private String username;
    private String password;
}

 

회원가입을 위한 API를 작성한다.

 

MemberRepository.java

package com.example.new_jwt_practice.repository;

import com.example.new_jwt_practice.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member,Long> {
    Boolean existsMemberByUsername(String username);
}

 

JoinService.java

package com.example.new_jwt_practice.service;

import com.example.new_jwt_practice.dto.JoinDTO;
import com.example.new_jwt_practice.entity.Member;
import com.example.new_jwt_practice.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class JoinService {
    private final MemberRepository memberRepository;
    private final BCryptPasswordEncoder passwordEncoder;

    public ResponseEntity<?> join(JoinDTO joinDTO) {

        // 동일 username 사용자 생성 방지
        if (memberRepository.existsMemberByUsername(joinDTO.getUsername())) {
            System.out.println("이미 존재하는 회원");
            return ResponseEntity.status(HttpStatus.FORBIDDEN).body("이미 존재하는 회원입니다.");
        }

        // 새로운 회원 생성
        Member member = Member.builder()
                .username(joinDTO.getUsername())
                // 비밀번호 암호화 해서 저장
                .password(passwordEncoder.encode(joinDTO.getPassword()))
                .role("ROLE_ADMIN") // 사용자 권한 설정 접두사 ROLE 작성 필요
                .build();

        System.out.println("회원 생성 완료");
        memberRepository.save(member);

        return ResponseEntity.ok("회원가입 성공");
    }
}

 

JoinController.java

package com.example.new_jwt_practice.controller;

import com.example.new_jwt_practice.dto.JoinDTO;
import com.example.new_jwt_practice.service.JoinService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class JoinController {

    private final JoinService joinService;

    @PostMapping("/join")
    public ResponseEntity<?> joinProcess(@RequestBody JoinDTO joinDTO) {
        // 회원 가입 진행
        return joinService.join(joinDTO);
    }

}

 

POSTMAN 을 사용하여 테스트해보면 회원가입에 성공하는 것을 확인할 수 있다.

POSTMAN 테스트
MySQL Workbench에 회원이 등록된 모습

 

6. 로그인 필터 구현

일반적으로 사용자가 api 요청을 보내게되면 Tomcat의 Servlet Filter들을 거친 후, DispatcherServlet을 거쳐 적절한 Controller 계층으로 보내지게 된다. 

스프링 시큐리티는 여러 필터들 중에 Delegating Filter Proxy 라는 하나의 필터를 생성하여 모든 요청을 가로채어 Security Filter Chain의 모든 필터들을 먼저 거치게 된다. (이는 사용자의 인증,인가를 수행 한 후 권한에 따라 API 호출을 수행하기 위함이다.)

-> 톰캣의 서블릿 필터와 Security Filter는 서로 다른 것이다.

 

가로채어진 요청은 Security Filter Chain에서 여러 Security Filter들을 거쳐 상황에 따른 거부, 리다이렉션, 서블릿으로 요청 전달을 진행한다.

 

스프링 시큐리티 프레임워크는 다양한 필터들을 제공하며 이 필터들 중 UsernamePasswordAuthenticationFilter, DigestAuthenticationFilter,BasicAuthenticationFilter,등을 순서대로 거쳐 인증, 인가를 진행한다.

 

Form 로그인 방식에서는 프론트엔드에서 username과 password를 전송한 뒤 Security 필터를 통과하는데 UsernamePasswordAuthenticationFilter에서 회원 검증 진행을 시작한다.

 

회원검증(Authentication)의 경우 UsernamePasswordAuthenticationFilte가 호출한 AuthenticationManager를 통해 진행되며 사용자가 전송한 username과 password을 바탕으로 DB에서 조회한 데이터를 UserDetailsService를 통해 전달받게 된다.

(이때 필요하다면 개발자가 UserDetailsService를 구체화하여 사용자가 전송한 username과 password를 바탕으로 조회된 엔티티로 UserDetails 객체를 생성하여 반환한다. - loadUserByUsername)

 

현재 진행중인 JWT 프로젝트에서는 formLogin 방식을 disable 해두었으므로 UsernamePasswordAuthenticationFilter는 동작하지 않게 된다. 따라서 로그인을 진행하기 위해 해당 필터를 커스텀하여 등록해야 한다.

 

6.1. 로그인 요청 받기 : 커스텀 UsernamePasswordAuthentication 필터 작성

사용자의 로그인 요청을 받기 위한 UsernamePassowrdAuthentication 필터를 커스텀하여 작성한다.

코드에 대한 설명은 코드블럭의 주석을 참고하길 바란다.

package com.example.new_jwt_practice.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
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;

// 폼 로그인 방식의 경우 스프링 시큐리티 필터들 중 UsernamePasswordAuthenticationFilter에 의해 수행된다.
// 해당 필터는 AuthenticationManager를 사용하여 사용자의 인증절차를 수행하는데
// 이때 UserDetailsService가 제공하는 UserDetails를 사용한다.
// 해당 프로젝트의 경우 폼 로그인 방식을 disable하였으므로
// 사용자의 로그인 요청을 수행하기 위한 UsernamePasswordAuthenticationFilter를 커스텀하여 로그인 요청을 수행한다.
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager; // 인증 절차를 수행할 때 사용

    @Override
    public Authentication attemptAuthentication(
            HttpServletRequest request,
            HttpServletResponse response) throws AuthenticationException {

        // 클라이언트 요청에서 username, passowrd 추출
        String username = obtainUsername(request);
        String password = obtainPassword(request);

        // 스프링 시큐리티에서 username과 password를 검증하기 위해서는 토큰에 담아야한다.
        // username, password, authorities
        UsernamePasswordAuthenticationToken authToken
                = new UsernamePasswordAuthenticationToken(username, password,null);

        // 토큰 정보를 바탕으로 검증 수행 및 결과 반환
        // 검증은 AuthenticationManager가 UserDetails를 바탕으로 수행한다.
        // DB의 정보를 바탕으로 UserDetailsService에 의해 UserDetails가 생성되면 AuthenticationManager가 이를 토대로 검증한다.
        Authentication authentication = authenticationManager.authenticate(authToken);

        return authentication;
    }

    // 로그인 성공시 실행
    // 로그인 성공시 사용자 정보를 담고 있는 JWT 토큰을 발급 해준다.
    @Override
    protected void successfulAuthentication(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain, Authentication authResult) throws IOException, ServletException {

    }

    // 로그인 실패시 실행

    @Override
    protected void unsuccessfulAuthentication(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException failed) throws IOException, ServletException {

    }
}

 

시큐리티 필터를 작성하였으니 스프링 시큐리티 프레임워크에 우리가 작성한 필터를 사용하도록 알려주어야 한다.

SecurityConfig에 우리가 작성한 필터를 등록한다.

package com.example.new_jwt_practice.config;

import com.example.new_jwt_practice.jwt.LoginFilter;
import lombok.RequiredArgsConstructor;
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.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity // 시큐리티를 위한 구성파일임을 알림
@RequiredArgsConstructor
public class SecurityConfig {

    // AuthenticationManager의 AuthenticationConfiguration에서 사용하기 위함
    private final AuthenticationConfiguration authenticationConfiguration;

    // 사용자 비밀번호를 관리하기 위한 암호화 클래스
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // 커스텀한 UsernamePasswordAuthentication Filter에서 사용하기 위해 Authentication 빈 등록
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        // csrf disable
        http
                .csrf(auth -> auth.disable()); // JWT 방식은 세션을 Stateless로 관리하기 때문에 csrf 공격에 비교적 안전하다.

        // JWT 방식을 사용할 것이므로 이외의 방식은 모두 비활성화 한다.
        // Form 로그인 방식 disable
        http
                .formLogin(auth -> auth.disable());

        // http basic 인증 방식 disable
        http
                .httpBasic(auth -> auth.disable());

        // 경로별 인가 설정
        http
                .authorizeHttpRequests(auth -> auth
                        // login, root, join 경로의 요청에 대해서는 모두 허용
                        .requestMatchers("/login", "/", "/join").permitAll()
                        // admin 경로 요청에 대해서는 ADMIN 권한을 가진 경우에만 허용
                        .requestMatchers("/admin").hasRole("ADMIN")
                        // 이외의 요청에 대해서는 인증된 사용자만 허용
                        .anyRequest().authenticated()
                );

        // 커스텀한 UsernamePasswordAuthenticationFilter를 사용하도록 알림
        // addFilterAt : 해당 필터를 대체한다.(그 자리를 대체한다.)
        // addFilterAt의 2번째 인자는 어떤 위치를 대체할것인지 해당 필터를 작성한다.(이 경우 Username.. Filter)
        http
                .addFilterAt(new LoginFilter
                                (authenticationManager(authenticationConfiguration)),
                        UsernamePasswordAuthenticationFilter.class);

        // 가장 중요
        // JWT 방식에서는 세션을 Stateless 상태로 관리하게 됨(서버 세션에 저장하지 않음)
        // 따라서 세션을 STATELESS 상태로 설정 해주어야 한다.
        http
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));



        return http.build();
    }
}

 

addFilterAt() 함수를 사용하여 특정 필터를 내가 작성한 커스텀 필터 코드로 대체할 수 있다.

앞서 작성한 LoginFilter는 AuthenticationManager를 전달받아야하므로 @Bean 어노테이션을 사용하여 AutehnticationManager를 스프링 빈으로 등록한다.

// 커스텀한 UsernamePasswordAuthentication Filter에서 사용하기 위해 Authentication 빈 등록
@Bean
public AuthenticationManager authenticationManager(
        AuthenticationConfiguration configuration) throws Exception {
    return configuration.getAuthenticationManager();
}

 

 

해당 구성 함수는 AutehnticationConfiguration으로부터 getAuthenticationManager를 호출함으로서 AuthenticationManager를 생성하므로 AuthenticationConfiguration을 주입받도록 한다.

 

그리하여 최종적으로 addFilterAt() 함수에 우리가 작성한 LoginFilter를 아래와 같이 등록할 수 있게 된다.

addFilterAt()의 두번째 인자로 UsernamePasswordAuthenticationFilter.class를 작성하여 해당 필터를 대체할 것임을 알린다.

http.addFilterAt(new LoginFilter
            (authenticationManager(authenticationConfiguration)),
		    UsernamePasswordAuthenticationFilter.class);

 

6.3. 데이터베이스 기반 로그인 검증 로직

앞서서 UsernamePasswordAuthenticationFilter는 AuthenticaitonManager를 사용하여 검증을 수행하고, AuthenticationManager는 UserDetails를 사용하여 사용자 정보를 확인한다고 설명하였다. 

UserDetails는 UserDetailsService에서 데이터베이스로부터 정보를 조회하여 생성된다.

기본적으로 UserDetailsService는 스프링 시큐리티 프레임에서 사용하는 User 엔티티를 사용하여 UserDetails를 생성하도록 구현되어있는데, 우리는 우리가 직접 작성한 Member 엔티티를 대상으로 위와 같은 과정을 수행하길 바란다.

따라서 우리는 UserDetailsService를 커스텀하여 구현할 필요가 있다.

 

package com.example.new_jwt_practice.jwt.service;

import com.example.new_jwt_practice.entity.Member;
import com.example.new_jwt_practice.jwt.dto.CustomUserDetails;
import com.example.new_jwt_practice.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
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;

import java.util.Optional;


// username, password를 바탕으로 회원 엔티티를 조회하고, 조회결과를 바탕으로 UserDetails를 생성하여 반환
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> optionalMember = memberRepository.findByUsername(username);
        if (optionalMember.isEmpty()) {
            return null;
        }
        Member member = optionalMember.get();

        // UserDetails를 구체화하는 클래스를 생성하여 반환
        return new CustomUserDetails(member);
    }
}

 

조회된 member 엔티티 정보를 바탕으로 UserDetails를 생성해주어야 하므로, UserDetails를 구체화하는 CustomUserDetails 도 작성한다.

package com.example.new_jwt_practice.jwt.dto;

import com.example.new_jwt_practice.entity.Member;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

public class CustomUserDetails implements UserDetails {

    private Member member;

    public CustomUserDetails(Member member) {
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();

        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return member.getRole();
            }
        });
        return collection;
    }

    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public String getUsername() {
        return member.getUsername();
    }
}

 

마지막으로 로그인이 성공했을 경우와 실패했을 경우를 살펴보기 위해 LoginFilter의 successfulAuthentication()과 unsuccessfulAuthentication()에 아래와 같이 작성한다.

@Override
protected void successfulAuthentication(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain chain, Authentication authResult) throws IOException, ServletException {
    System.out.println("success!");
}

// 로그인 실패시 실행

@Override
protected void unsuccessfulAuthentication(
        HttpServletRequest request,
        HttpServletResponse response,
        AuthenticationException failed) throws IOException, ServletException {
    System.out.println("fail!");
}

 

POSTMAN을 통해 테스트를 진행해보면 아래와 같이 success가 뜨는것을 확인 할 수 있다.

 

위 과정을 진행한 뒤 몇가지 의문점이 발생하여 자료를 찾아보았다.

 

1. 우리는 분명 /login 과 관련된 Controller를 작성한 적이 없다. 그렇다면 어떻게 /login 엔드포인트를 사용하여 사용자 검증이 이루어지는가?

 

/login 경로에 대한 요청이 정상적으로 처리되는 이유는 UsernamePasswordAuthenticationFilter 때문이다.

스프링 시큐리티는 기본적으로 UsernamePasswordAuthenticationFilter를 사용하여 /login 경로에서 로그인 요청을 처리한다.

이 필터가 폼 데이터의 username과 password를 추출하고, AuthenticationManager를 사용하여 인증 절차를 수행합니다. 따라서 별도의 컨트롤러를 작성하지 않아도 /login 경로로 로그인 요청을 보낼 수 있게된다.

 

앞서 설명하였듯 사용자 요청이 전달되면 여러가지 필터들을 거친 뒤 DispatcherServlet에 의해 적잘한 Controller로 매칭된다. 하지만 위 요청의 경우 Filter단에서 수행되는 로직이기 때문에 DispatcherServlet에 의한 매핑전에 수행된다. 따라서 /login은 별도의 Controller 코드 없이 동작할 수 있게 된다.

 

2. CustomUserDetailService와 LoginFIlter 코드를 살펴보면 사용자의 비밀번호가 데이터베이스에 저장된 비밀번호와 일치하는지 확인하는 로직이 존재하지 않는다. 어떻게 비밀번호 검증을 수행하는 걸까?

 

사용자가 로그인을 요청하게 되면 사용자 정보는 CustomUserDetailsService에 의해 UserDetails를 구체화한 CustomUserDetails 객체로 반환된다. 이 객체는 데이터베이스에 저장된 username과 암호화되어 저장된 password를 보유하고 있다.

사용자의 로그인 요청을 처리하는 필터인 UsernamePasswordAuthenticationFilter는 전달받은 사용자 이름을 바탕으로 검증을 수행하며 AuthenticationManager에게 이 일을 위임한다. AuthenticaionManager는 AuthenticationProvider를 통해 실제 인증 작업을 수행하는데 이때 사용자가 전달한 비밀번호를 BcryptEncoder를 통해 암호화하여 해당 해시값과 UserDetails에 저장된 해시값이 일치하는지 확인함으로서 비밀번호가 일치하는지 확인한다.

따라서 별도로 회원의 비밀번호가 일치하는지 확인하는 로직을 작성할 필요가 없다.

 

3. 회원가입 로직을 작성할 때 사용자 비밀번호를 BcryptEncoder를 사용하여 암호화하여 저장하였다. 하지만 사용자가 로그인할 때 입력하는 값은 암호화되지 않은 값이다. 어떻게 두 값이 같은지 확인할까?

 

2번에서 설명하였듯이 AuthenticationManger에서 사용자가 입력한 비밀번호를 BcryptEncoder를 사용하여 암호화한뒤 해당 해시값과 UserDetails에 존재하는 사용자 비밀번호 해시값이 일치하는지 확인함으로서 두 값이 일치하는지 확인한다.

이때 암호화에 사용되는 인코더는 passwordEncoder라는 이름으로 등록된 빈을 사용한다. 즉, BcryptEncoder가 아닌 다른 방식으로 비밀번호를 암호화하고 비교하고 싶다면 해당 인코더 클래스를 passwordEncoder 라는 이름의 빈으로 등록하면 된다.

'Backend' 카테고리의 다른 글

[AWS] 네트워크 구축  (0) 2024.12.08
[SpringBoot] Spring Securtiy JWT(2)  (1) 2024.06.22
[SpringBoot] Spring Securtiy (2)  (1) 2024.06.14
[SpringBoot] Spring Securtiy (1)  (0) 2024.06.11
[Spring] Spring Initializr Naming Rule  (0) 2024.06.08
728x90

해당 포스트는 유튜브 '개발자 유미' 님의 스프링 시큐리티 강의 영상을 바탕으로 작성하였습니다.

https://www.youtube.com/playlist?list=PLJkjrxxiBSFCKD9TRKDYn7IE96K2u3C3U

 

스프링 시큐리티

스프링 시큐리티 : Spring Security

www.youtube.com

 

지난 포스트 보러가기

https://76codefactory.tistory.com/70

 

[SpringBoot] Spring Securtiy (1)

해당 포스트는 유튜브 '개발자 유미' 님의 스프링 시큐리티 강의 영상을 바탕으로 작성하였습니다.https://www.youtube.com/playlist?list=PLJkjrxxiBSFCKD9TRKDYn7IE96K2u3C3U 스프링 시큐리티스프링 시큐리티 : Spri

76codefactory.tistory.com

 

이전 포스트에서 작성한 SecurityConfig 파일을 다시 한번 살펴보자

 

 

이 SecurityConfig 구성파일에 의해 /admin 경로로의 요청에 대해 SecurityFilterChain에 의해 ADMIN 권한을 보유한 사용자에 한하여 접근을 허용하게 된다.

 

그로 인해 admin 페이지에 접근하게 되면 아래와 같이 액세스가 거부되는 것을 확인 할 수 있다.

 

액세스가 거부되는 원인은 현재 요청을 한 사용자가 인증(Authentication)을 수행하지 않았고 그로 인해 사용자의 권한을 확인할 수 없어 인가(Authorization)을 수행할 수 없기 때문이다.

 

이 문제를 해결하기 위해 먼저 사용자가 요청을 할 때 인가 처리를 위한 인증 작업이 선행되어야 한다.

 

1.  커스텀 로그인

앞서 말한 인증을 위해 커스텀 로그인 설정을 작성해보자. 별도로 SecurityConfig파일을 작성하지 않았다면 스프링 시큐리티 프레임워크의 기본 설정에 의해 스프링 시큐리티가 제공하는 로그인 창으로 넘어가게 되나, SecurityConfig 파일을 작성하였다면 직접 로그인 페이지에 대해서도 설정을 해주어야 한다.

 

먼저 로그인 페이지로 사용할 login.html 파일을 작성한다.

 

templates - login.html

 

추가로 /login 요청시 로그인페이지로 연결해주기 위한 LoginController도 작성해준다.

 

LoginController.java

 

서버를 실행 시킨 후 /login 경로로 이동하여 해당 페이지가 잘 실행되는지 확인한다.

 

 

해당 페이지를 통해 사용자가 아이디와 비밀번호를 입력하고 login 버튼을 누르게되면, form 태그의 action에 의해 /loginProc 에 POST요청으로 사용자의 아이디와 비밀번호가 전달된다. 그러므로 /loginProc 에 대한 매핑 함수를 작성한 뒤 사용자가 입력한 아이디와 비밀번호를 통해 인증과 인가 절차를 수행하는 코드를 작성해야 한다.

 

이제 로그인 페이지를 만들었으니 /admin 로 요청시 인증을 수행하기위해 /login 요청으로 연결시켜주어야 한다.

이를 위해 SecurityConfig 파일의 filterChain 함수를 아래와 같이 수정한다.

 

.formLogin() 함수를 통해 인증이 필요할 때 /login 으로 연결시키게 되며 로그인 절차를 수행할 때 /loginProcessingUrl 에서 수행하도록 설정한다. 또한 /login 페이지로의 요청은 .permitAll()을 통해 모두 허용하도록 한다.

 

스프링은 기본 설정으로 CSRF 설정이 활성화되어 있다. 사용자의 로그인 요청은 POST 요청을 통해 수행되는데 CSRF 설정이 활성화 되어있다면 POST 요청시 항상 CSRF 토큰을 삽입해서 보내야만한다. 개발과정의 편의를 위해 임시로 비활성화 해두기로 한다.

 

서버를 다시 실행시킨 후 /admin 페이지로 접근시 /login 페이지로 리다이렉트 되는것을 확인 할 수 있다.

 

2. 암호화 메서드 작성

스프링 시큐리티는 회원가입된 사용자의 비밀번호가 암호화 되어있다는 것을 전제로 한다.

따라서 사용자 정보를 DB에 저장하기 전에 비밀번호를 암호화한 뒤 저장해두어야 나중에 인증 작업을 수행할 수 있다.

암호화 방법에는 여러가지 방법이 있지만, 스프링 시큐리티는 암호화를 위해 BCrypt Password Encoder를 제공하며 권장하므로 해당 클래스를 Bean으로 등록하여 어디서든 사용가능하게 설정하도록 하겠다.

 

SecurityConfig 파일에 아래와 같은 Bean 설정을 추가하여 BCryptPasswordEncoder를 스프링 빈으로 등록시키도록 한다.

 

 

 

3. 데이터베이스 연결

스프링 시큐리티를 통해 사용자가 실제로 존재하는 사용자인지 '인증(Authentication)' 하고 적절한 권한을 갖고 있는지 '인가(Authroization)'을 수행한다고 하였다.

그렇기에 사용자 정보가 실제로 어딘가에 저장되어야 할 것이다. 이를 위해 미루어두었던 데이터베이스 연결을 시행한다.

 

이전에 주석처리 해두었던 의존성들을 다시 활성화 한다.

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.mysql:mysql-connector-j'

 

application.properties 파일에 아래와 같이 데이터베이스 설정 정보를 기입한다.

사전에 MySQL에서 spring-security 라는 이름의 데이터베이스를 생성하였다.

 

4. 회원 가입 로직 작성

이제 본격적으로 회원가입 로직을 작성해보자.

앞서 말했듯이 스프링 시큐리티는 회원정보를 통해 인증과 인가를 진행하기 때문에 회원 가입, 로그인 로직을 작성해주어야한다.

 

회원가입을 위한 join.html 과 JoinController를 작성해보자.

 

join.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>JOIN</title>
</head>
<body>
    <h1>This is Join Page</h1>
    <form action="/joinProc" method="post" name="joinForm">
        <input type="text" name="username" placeholder="Username"/>
        <input type="password" name="password" placeholder="Password"/>
        <input type="submit" value="Join"/>
    </form>

</body>
</html>

 

JoinController.java

 

마지막으로 회원가입 페이지에 대한 접근 설정을 추가해주어야한다. 회원가입 페이지는 모든 사용자에게 접근을 허용하는 것이 일반적이므로 SecurityConfig 의 SecurityFilterChain의 requestMatchers에 "join"으로의 경로는 모두 허용함을 설정해주어야 한다.

 

현재까지 작성된 SecurityConfig.java 파일은 아래와 같다.

package com.example.springsecuritystudy.config;

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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

// 커스텀 인증과 인가를 위한 구성파일
@Configuration
@EnableWebSecurity // 스프링 시큐리티 활성화
public class SecurityConfig {

    // 비밀번호 암호화를 위한 빈 생성
    @Bean // 빈으로 등록시킴으로서 어디서든 사용할 수 있게된다.
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }


    // 필터 빈 생성
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        // HttpSecurity 빈에 아래 설정 추가
        http
                .authorizeHttpRequests(auth -> auth // 람다 식으로 작성 해야함
                        // 루트 디렉토리, login 디렉토리는 모두 허용(인증 필요x)
                        .requestMatchers("/", "/login","/join").permitAll()
                        // admin 페이지는 인증된 사용자가 ADMIN 권한을 갖고 있어야함(인가)
                        .requestMatchers("/admin").hasRole("ADMIN")
                        // my경로 이후의 모든 경로에 대해 ADMIN 혹은 USER권한 보유시 접근 가능
                        .requestMatchers("/my/**").hasAnyRole("ADMIN", "USER")
                        // 위에서 설정하지 않은 모든 요청은 인증(Authenticated)을 거쳐야 접근 가능(로그인 필요)
                        .anyRequest().authenticated()
                )
                // 로그인 설정 추가
                .formLogin(auth -> auth
                        .loginPage("/login") // 로그인 페이지로 리다이렉트
                        .loginProcessingUrl("/loginProc")
                        .permitAll()
                )
                // 로그인시 POST 사용을 위해 csrf 설정 해제
                // csrf 설정은 디폴트로 작동된다. => csrf가 동작되면 POST 요청을 보낼때 csrf 토큰도 보내야 요청이 수행된다.
                // 하지만 개발환경에서는 토큰을 따로 보내주도록 설정하지 않았기 때문에 임시로 비 활성화 한다.
                .csrf(auth -> auth.disable());

        return http.build();
    }
}

 

회원 가입을 위한 JoinDTO, UserEntity, JoinService, UserRepository도 차례로 작성해주도록 한다.

 

JoinDTO

사용자가 입력한 값들을 가져오기 위해 사용하는 DTO클래스, dto 패키지 하단에 작성한다.

 

폼을 통해 사용자 정보를 얻어오기 위해 JoinController도 아래와 같이 수정한다.

 

UserEntity

사용자 정보를 저장하기 위한 엔티티, entity 패키지 하단에 작성한다.

동일한 이름을 가진 회원이 생성되는 것을 방지하기 위해 unique = true 속성을 부여한다.

 

UserRepository

사용자 정보를 DB에 저장하기 위한 레파지토리 클래스, Spring Data JPA 라이브러리를 사용하여 인터페이스만으로 기본적인 CRUD 함수들이 자동구현된다. repository 패키지 하단에 작성한다.

동일 이름을 가진 회원이 생성되는 것을 방지하기 위한 함수로 existsByUsername(String username) 을 작성한다. 이 함수는 Spring Data JPA에 의해 구체화되며, username에 해당하는 회원이 존재할 경우 true를, 존재하지 않는 경우 false를 리턴한다.

 

 

JoinService

회원가입을 수행하기 위한 서비스 계층 클래스에 해당한다. service 패키지 하단에 작성한다.

사용자의 비밀번호를 암호화하여 저장하기 위해 BCryptPasswordEncoder를 주입받는다.

userRepository의 existsByUsername()를 호출하여 동일한 이름의 회원이 생성되는 것을 방지한다.

 

 

이후 join 페이지에 접속하여 회원가입을 진행해보면 아래와 같이 정상적으로 저장되는것을 MySQL Workbench에서 확인할 수 있다.

 

 

5. DB 기반 로그인 검증 로직

사용자가 인증(Authentication)을 위해 로그인을 시도하면 스프링 시큐리티는 UserDetailsService 라는 서비스계층에서 사용자 정보를 조회하여 검증을 수행한다.  UserDetailsService는 UserDetails 객체를 반환하는데, 이는 스프링 시큐리티가 사용자 정보를 저장하고 관리하는 데 사용된다.

기본적으로 스프링 시큐리티는 User 엔티티를 사용하여 로그인 과정을 처리한다. 하지만, 사용자 정의 엔티티(이 게시글의 UserEntity.class에 해당)를 사용하여 로그인 과정을 처리하고자 할 경우, UserDetailsService를 구현한 CustomUserDetailsService를 정의해주어야 한다.

UserDetailsService 인터페이스는 loadUserByUsername(String username) 메서드를 제공한다. 이 메서드는 사용자 이름(username)을 통해 UserDetails 객체를 반환하는 역할을 한다. 따라서, 사용자 정의 엔티티를 사용하여 인증 절차를 구현하기 위해서는 CustomUserDetailsService 클래스에서 loadUserByUsername() 메서드를 오버라이딩하여, 사용자 이름을 통해 사용자 정보를 조회하고, 이를 기반으로 UserDetails 객체를 반환하도록 구현해야한다.

 

이제부터 이 구현과정을 차근차근 진행해보자

service 디렉토리 밑에 CustomUserDetailService 를 작성한다.

CustomUserDetailService의 이름은 무엇으로 짓든 상관 없으며, UserDetailsService를 구체화하여 스프링 시큐리티가 이 서비스 클래스를 통해 인증/인가 를 수행하도록 한다. 

UserDetailsService는 loadUserByUsername 함수를 사용하여 UserDetails를 생성하여 사용자 정보를 관리하는데, CustomUserDetailsService는 이를 상속받았으므로 해당 함수를 구체화 해주어야한다.

 

매개변수로 전달받은 username(사용자 이름)을 바탕으로 우리가 정의한 커스텀 엔티티(UserEntity)를 정의한다음, 해당 엔티티로부터 정보를 추출하여 UserDetails를 생성하여 반환해주면 앞서 정의한 SecurityConfig로 전달되어 비로서 사용자의 인증/인가가 진행되는 것이다.

 

UserDetails를 생성하여 반환하기 위해 CustomUserDetails라는 객체를 정의하도록 한다. 해당 객체는 UserDetails 인터페이스를 구체화하는 것으로 여러가지 함수를 구체화해주어야 한다. 또한 CustomUserDetailsService로부터 엔티티 정보를 전달받기 위해 UserEntity를 매개변수로 전달받는 생성자를 추가로 작성해주었다.

 

필수적으로 구현해주어야 하는 함수들은 아래와 같다.

1. getAuthorities() : 사용자가 보유한 권한들을 Collection<GrantedAuthority> 형태로 반환한다. 엔티티가 보유중인 권한들을 바탕으로 GrantedAuthority()를 생성한 뒤 컬렉션에 삽입하고 반환한다.

2. getPassword() : 사용자 비밀번호를 반환한다.
3. getUsername() : 사용자의 Username을 반환한다.

public class CustomUserDetails implements UserDetails {

    private UserEntity userEntity;

    public CustomUserDetails(UserEntity userEntity) {
        this.userEntity = userEntity;
    }

    // 사용자의 권한을 반환한다. // 데이터베이스에 저장한 ROLE에 해당한다.
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        Collection<GrantedAuthority> collection = new ArrayList<>();

        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return userEntity.getRole(); // 사용자의 권한 리턴
            }
        });

        return collection;
    }

    // 사용자 비밀번호를 반환한다.
    @Override
    public String getPassword() {
        return userEntity.getPassword();
    }

    // 사용자 username을 반환한다.
    @Override
    public String getUsername() {
        return userEntity.getUsername();
    }
}

 

그럼 이제 테스트를 진행해보자. 

SecurityConfig 코드를 다시 살펴보면 /admin 페이지로의 접근에 대해 ADMIN 권한을 보유해야 접근이 가능한것으로 되어있다.

 

정상적으로 동작하는지 확인하기 위해 ADMIN 권한을 보유한 새 유저를 생성하고 해당 유저로 admin 페이지로의 접근을 시도해보겠다.

JoinService의 joinProcess() 함수에서 role을 "ROLE_ADMIN"으로 수정한뒤, 회원 생성을 진행한다.

 

회원가입 페이지에 접근한 뒤 admin을 username으로 하여 회원가입을 진행한다.

확인결과 ROLE_ADMIN 권한을 가진 사용자가 생성되었다. 

 

이제 admin 페이지로 접근해보자. 그럼 당연하게도 사용자 권한을 확인하기 위해 /login 페이지로 리다이렉트 된다.

앞서 회원가입한 ADMIN권한을 가진 계정으로 로그인하여 접근이 되는지 확인해보면 성공적으로 접근되는것을 확인할 수 있다.

 

만약 로그인에 성공하였지만 권한이 달라 접근할 수 없다면 아래와 같이 white label이 보여지는 것을 알 수 있다.

 

 

6. 세션 사용자 아이디 정보

SecurityConfig에서 정의한 Spring Security Filter Chain에 의해 사용자의 로그인이 이루어지면, 스프링 시큐리티는 사용자 정보(UserDetails)를 세션에 저장한다. 

구체적으로 설명하면 아래와 같다.

  1. 로그인 과정:
    • 사용자가 로그인 폼을 제출하면, 스프링 시큐리티는 UsernamePasswordAuthenticationFilter를 사용하여 인증 요청을 처리한다.
    • 이 필터는 사용자가 입력한 사용자 이름과 비밀번호를 AuthenticationManager를 통해 인증한다.
    • AuthenticationManager는 여러 AuthenticationProvider를 사용하여 실제 인증을 수행하며, 이 과정에서 UserDetailsService를 통해 사용자 정보를 조회한다.
  2. UserDetails 저장:
    • 인증이 성공하면, 스프링 시큐리티는 인증된 사용자 정보를 나타내는 Authentication 객체를 생성한다. 이 객체는 UserDetails를 포함한다.
    • 생성된 Authentication 객체는 SecurityContextHolder를 통해 SecurityContext에 저장된다. 기본적으로 SecurityContext는 세션에 저장되므로, 사용자 정보(UserDetails)도 세션에 저장된다.
  3. 세션 관리:
    • 세션에 저장된 SecurityContext는 사용자가 애플리케이션 내에서 이동할 때마다 인증 상태를 유지하는 데 사용된다. 이를 통해 사용자는 로그인 상태를 유지하고, 권한 검사를 받을 수 있게된다.

 

네이버, 구글, 등의 사이트에서 로그인에 성공하게되면 권한이 필요한 다른 요청들에 대해서 여러번 로그인을 할 필요없이 사용자 정보가 유지되는 것 또한 이러한 원리를 바탕으로 한다.

 

깊은 이해를 위해 앞서 작성한 메인페이지를 수정해보자.

 

스프링 시큐리티를 통해 저장된 사용자 정보는 SecurityContextHolder를 통해서 얻을 수 있다.

아래와 같이 코드를 작성한뒤 model에 데이터를 담고 html 페이지에서 값을 확인해보자.

 

main.html

 

서버를 실행하고 메인페이지로 접속하면 아직 로그인을 하지 않았기 때문에 anonymouseUser라고 출력된다.

 

이후 로그인을 하고 다시 메인페이지에 접속하게 되면 아래와 같이 현재 로그인한 사용자의 정보와 권한 정보를 확인할 수 있다.

 

이후 새로고침을 아무리 진행하더라도 사용자의 정보는 SecurityContext에 담겨져있기 때문에 사용자 정보가 유지되는것을 확인 할 수 있다.

 

7. 세션 설정

사용자가 로그인을 진행하면 사용자 정보는 SecurityContextHolder라는 서버 세션에 저장되고, 사용자에게는 이 세션에 대한 세션 ID를 반환하여 프론트 단에서 쿠키로 관리하게 된다. 이후 프론트 엔드에서 서버로 요청을 보낼 때 마다 이 세션 ID를 함께 보내게 되면 서버는 사용자가 현재 로그인한 사용자임을 알 수 있으며 필요할 때 사용자에 대한 정보를 얻어올 수 있게 되는 것이다.

 

하지만 이러한 세션 기반의 사용자 관리 방식은 접속한 사용자의 수가 많아지면 많아질 수록 서버 세션에 현재 로그인한 사용자를 저장하고 관리해야하므로 서버의 성능이 하락될 수 있다. 이를 방지 하기위해 사용자 정보를 서버 세션에 저장하지 않는, Stateless한 방식으로 JWT를 활용하는 방식이 있으나 이에 대해서는 나중에 포스트 하도록 하고 먼저 세션을 효율적으로 관리하기 위한 설정들에 대해 알아보겠다.

 

1. 세션 소멸 시간 설정

세션 타임아웃 설정을 통해 로그인 이후 세션이 유지되고 소멸되는 시간을 설정 할 수 있다.

세션 소멸 시점은 서버에 마지막 특정 요청을 수행한 뒤 설정한 시간이 지나면 세션이 소멸되게 된다. 스프링에서 제공하는 기본 설정은 1800초(30분)이므로, 서버 환경에 알맞게 적절한 값으로 설정 해야한다.

설정은 application.properties에 아래와 같이 작성함으로서 가능하다.

 

 

2. 다중 로그인 설정

하나의 아이디에 대해서 세션을 몇개까지 생성가능한지 설정할 수 있다. 

SecurityConfig에서 설정이 가능하다.

 

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{

    http
            .sessionManagement((auth) -> auth
                    .maximumSessions(1)
                    .maxSessionsPreventsLogin(true));

    return http.build();
}

 

maximumSession(Integer) : 하나의 아이디에 대한 다중 로그인 허용 개수를 설정한다.

maxSessionPreventsLogin(Boolean) : 다중 로그인 개수를 초과하였을 경우 처리 방법을 정의한다.

  • true : 초과시 새로운 로그인 차단
  • false : 초과시 기존 세션 하나 삭제

3. 고정 보호

 

세션 고정 공격을 보호하기 위한 로그인 성공시 세션 설정 방법은 sessionManagement() 메소드의 sessionFixation() 메소드를 통해서 설정할 수 있다.

  • sessionManagement().sessionFixation().none() : 로그인 시 세션 정보 변경 안함
  • sessionManagement().sessionFixation().newSession() : 로그인 시 세션 새로 생성
  • sessionManagement().sessionFixation().changeSessionId() : 로그인 시 동일한 세션에 대한 id 변경

 

 

728x90

해당 포스트는 유튜브 '개발자 유미' 님의 스프링 시큐리티 강의 영상을 바탕으로 작성하였습니다.

https://www.youtube.com/playlist?list=PLJkjrxxiBSFCKD9TRKDYn7IE96K2u3C3U

 

스프링 시큐리티

스프링 시큐리티 : Spring Security

www.youtube.com

 

목표

스프링 시큐리티 프레임워크를 활용하여 인증(Authentication)과 인가(Authorization)을 구현한다.

 

구현 내용

1. 인증 : 로그인을 통한 사용자 인가

2. 인가 : 사용자의 권한에 따른 경로별 접근 허용/비허용

3. 회원가입

 

시큐리티 동작 원리

스프링 부트 어플리케이션은 서블릿 컨테이너(Servlet Container) 내부에 위치한다. 사용자의 요청이 도달하게 되면 서블릿 컨테이너 내부의 필터들(Servlet Filters)를 거친후에 스프링 부트 컨트롤러에 요청이 도착하게된다.

Spring Security Config 라는 구성파일을 만들어두면, 해당 구성파일에 의해 Spring Security Filter라는 Servlet Filter가 생성되고 해당 필터가 사용자의 요청을 가로채게 된다. 이후 사용자의 인증(사용자가 실제로 해당 서비스의 사용자인지 확인 => 데이터베이스에 존재하는 회원인지 확인)과 인가(사용자가 특정 요청을 할 수 있는 권한이 있는지 확인)를 거치게 된다.

 

버전

- Spring Boot 3.3.0

- Security 6.1.5

- Spring Data JPA - MySQL

- mustache

- IntellJ Ultimate

 

1. 프로젝트 생성

의존성

필수 의존성

- Spring Web

- Lombok

- Thymleaf

- Spring Security

- Spring Data JPA

- MySQL Driver

 

프로젝트 생성

 

 

버전 선택(3.3.0) 및 의존성 6개 추가 후 프로젝트 생성

 

데이터베이스 설정을 뒤로 미루기 위해 MySQL과 Spring Data JPA 주석 처리 후 재빌드

 

2.  Config 파일 작성

별도의 설정을 하지 않으면 스프링 시큐리티에 의해 모든 사용자 요청에 대해서 Spring Security Filter Chain의 인증과 인가 과정을 수행하게 된다.

 

아래와 같이 MainController.java와 resource - template 경로에 main.html 파일을 작성한다.

 

서버를 실행시키게 되면 스프링 시큐리티에 의해 인증(사용자가 실제로 존재하는 사용자인지 확인)절차를 수행하게 된다.

인증을 위해서는 기본 사용자가 필요한데, 스프링을 실행시킨뒤 로그를 살펴보면 기본 사용자명 user에 대한 비밀번호를 생성하여 제공한다. 

security password : 0fe01f78-e22f-431c-94cf-df32768e62bd 이 비밀번호에 해당한다.

 

localhost:8080/ 를 통해 루트 디렉토리에 접근하게 되면, main페이지로 이동하게 되는데 스프링 시큐리티에 의해 해당 요청을 스프링 시큐리티 필터가 가로채게 되고, 로그인 페이지로 넘어가게 되어 인증절차를 수행하게 된다.

 

여기서 Username : user, Password : 0fe01f78-e22f-431c-94cf-df32768e62bd (스프링에 의해 생성된 비밀번호)를 작성하고 Sign in을 누르게 되면 인증(Authentication)절차를 통과하여 루트 디렉토리로 넘어가게 된다.

 

 

Config 파일 작성

디폴트 설정은 위와 같이 모든 경로에 대해 인증과 인가 작업을 수행하도록 되어있지만 특정 요청(특정 페이지로의 접근,등)에 대해서만 인증 및 인가 작업을 수행하도록 시키고 싶다.

이를 위해 Spring Security Filter Chain을 생성하는 구성파일을 작성해주어야 한다.

 

config 디렉토리를 만들고 SecurityConfig.java 파일을 작성한다.

 

스프링 시큐리티의 구성파일로 활성화 시키기 위해, @EnableWebSecurity 를 붙여주고, 구성 클래스로 생성하기 위해 @Configuration을 작성한다.

 

authorizeHttpRequests는 사용자 요청에 대한 인가를 설정한다. 람다식의 형식으로 작성하게 되며 requestMatchers를 통해 사용자의 요청 경로에 따라 인증,인가 조건을 설정한다.

 

- requestMatchers() : 사용자의 요청에 따른 인증,인가 조건을 설정한다.

- permitAll() : 모든 사용자에 대해 접근을 허용한다.  위 예제에선, 루트 디렉토리와 로그인 페이지는 인증(Authentication)을 수행하지 않은 사용자도 접근을 허용하도록 하였다.

- hasRole(ROLE) : 인증 절차를 마친 사용자에 대해 ROLE 권한을 가진 사용자의 접근을 허용한다.

- hasAnyRole(ROLE1,ROLE2 ..) : ROLE1, ROLE2 .. 중 하나의 권한이라도 가진 사용자의 접근을 허용한다.

- anyRequest() : requestMatchers()를 통해 설정하지 않은 모든 요청 경로에 대해 설정한다.

- authenticated() : 인증된 사용자라면 모두 허용한다.

 

위와 같이 구성파일을 작성한 뒤 서버를 다시 실행시켜보면 메인페이지(localhost:8080/)로 접근시 우리가 정의한 Security Filter Chai의 새로운 인가 규칙에 따라 루트 디렉토리에 대해 permitAll()이 수행되어 더 이상 인증(로그인)을 하지 않고도 접근이 가능해진다.

 

 

그렇다면 localhost:8080/admin 으로 접근시 어떤 일이 일어나는지 확인해보자.

우선 아직 해당 경로에 대한 api가 작성되지 않았으므로 MainController 때처럼 AdminController를 작성하고, admin.html 파일을 작성한다.

 

서버를 다시 실행시킨 후 admin 페이지로 접근하게 되면 아래와 같이 '액세스가 거부됨' 이라는 페이지가 뜨는 것을 확인 할 수 있다

 

액세스가 거부되는 것은 요청한 사용자가 인증(Authentication)을 수행하지 않았으며 그에 따라 권한도 확인 할 수 없기 때문이다.

 

다음 포스트에서는 이처럼 사용자가 권한이 필요한 페이지에 접근시 로그인 페이지를 띄워 인증 절차를 수행하고 인증된 사용자의 권한을 확인하여 접근을 허용할지 비허용할지 결정하는 방법에 대해 작성하도록 하겠다.

728x90

Spring 프로젝트를 시작할 때 Spring Initializr(https://start.spring.io/)를 사용하여 프로젝트를 생성하고는 한다.

프로젝트를 생성할 때마다 네이밍 규칙이 헷갈리므로 정리해두고자 한다.

 

Group

프로젝트를 정의하고 구분하는 고유 식별자다.

일반적으로 회사 또는 프로젝트의 도메인 명을 입력하도록 하며, 도메인 명을 역순으로 작성하는것이 관례이다.

예를 들어 도메인 명이 example.com 이라면 예시에서 처럼 com.example로 작성하면 된다.

 

Artifact

프로젝트의 이름을 의미한다. 프로젝트를 빌드하면 jar파일의 이름은 Artifact의 이름을 따르게 된다.

자유롭게 설정가능하나, 소문자만 사용하고 특수문자는 사용하지 않는것이 좋다.

 

Name

Artifact의 이름과 동일하게 설정한다.

Name의 이름에 따라 프로젝트에 [Name]Application이라는 메인 클래스가 생성된다.

예를 들어 프로젝트의 이름이 demo라면, demoApplication로 생성된다.

 

Description

프로젝트에 대한 간단한 설명을 작성한다. 

 

Pacakge

프로젝트의 루트 패키지를 설정하는 항목이다. 수정하지 않는다면 Group + Artifact의 이름으로 루트 패키지가 생성된다.

 

728x90

인프런 김영한 강사님의 '실전! 스프링 부트와 JPA 활용 2 - API 개발과 성능 최적화' 의 섹션 4 - 1 강의에서 발생한 오류이다.

 

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-JPA-API%EA%B0%9C%EB%B0%9C-%EC%84%B1%EB%8A%A5%EC%B5%9C%EC%A0%81%ED%99%94/dashboard

 

실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 | 김영한 - 인프런

김영한 | 스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., 스프링 부트, 실무에서 잘 쓰고 싶다면? 복잡한 문제까지 해결하는 힘을 길

www.inflearn.com

 

오류가 발생한 코드는 아래와 같다.

package utilizingjpa.jpashop.api;

import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import utilizingjpa.jpashop.domain.Address;
import utilizingjpa.jpashop.domain.Order;
import utilizingjpa.jpashop.domain.OrderItem;
import utilizingjpa.jpashop.domain.OrderStatus;
import utilizingjpa.jpashop.repository.OrderRepository;
import utilizingjpa.jpashop.repository.OrderSearch;

import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    // 엔티티를 직접 노출하는 경우(컬렉션 노출)
    @GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        // Lazy Loading 처리(강제 초기화)
        for (Order order : all) {
            order.getMember().getName();
            order.getDelivery().getAddress();
            // 조회하고 싶은 정보 -> orderItems
            List<OrderItem> orderItems = order.getOrderItems();
            // Lazy Loading 처리(강제 초기화)
            orderItems.stream().forEach(o -> o.getItem().getName());
        }
        return all;
    }

    // 엔티티를 DTO로 변환
    // 컬렉션 내부의 모든 컬렉션들을 DTO로 변환한 뒤 리턴(엔티티 직접 노출 x)
    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> collect = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());
        return collect;
    }

    static class OrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItem> orderItems;
        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems();
        }
    }

}

 

위 코드는 조회된 컬렉션 데이터를 DTO로 변환시켜 반환하는 코드로 api/v2를 포스트맨에서 호출하게 되면 아래와 같은 오류가 발생하게 된다.

 

 

{
    "timestamp": "2024-05-24T06:20:43.208+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "trace": "org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type, class utilizingjpa.jpashop.api.OrderApiController$OrderDto]\n\tat org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.writeInternal(AbstractJackson2HttpMessageConverter.java:489)\n\tat org.springframework.http.converter.AbstractGenericHttpMessageConverter.write(AbstractGenericHttpMessageConverter.java:114)\n\tat org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor.writeWithMessageConverters(AbstractMessageConverterMethodProcessor.java:297)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.handleReturnValue(RequestResponseBodyMethodProcessor.java:190)\n\tat org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite.handleReturnValue(HandlerMethodReturnValueHandlerComposite.java:78)\n\tat org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:136)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:917)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:829)\n\tat org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)\n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)\n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979)\n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)\n\tat org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)\n\tat jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)\n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)\n\tat jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:205)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)\n\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)\n\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)\n\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)\n\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)\n\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167)\n\tat org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)\n\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482)\n\tat org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115)\n\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)\n\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)\n\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:340)\n\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:391)\n\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)\n\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896)\n\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1744)\n\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)\n\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)\n\tat java.base/java.lang.Thread.run(Thread.java:1589)\nCaused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class utilizingjpa.jpashop.api.OrderApiController$OrderDto and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0])\n\tat com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:77)\n\tat com.fasterxml.jackson.databind.SerializerProvider.reportBadDefinition(SerializerProvider.java:1308)\n\tat com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:414)\n\tat com.fasterxml.jackson.databind.ser.impl.UnknownSerializer.failForEmpty(UnknownSerializer.java:53)\n\tat com.fasterxml.jackson.databind.ser.impl.UnknownSerializer.serialize(UnknownSerializer.java:30)\n\tat com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serializeContents(CollectionSerializer.java:145)\n\tat com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:107)\n\tat com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:25)\n\tat com.fasterxml.jackson.databind.ser.DefaultSerializerProvider._serialize(DefaultSerializerProvider.java:479)\n\tat com.fasterxml.jackson.databind.ser.DefaultSerializerProvider.serializeValue(DefaultSerializerProvider.java:399)\n\tat com.fasterxml.jackson.databind.ObjectWriter$Prefetch.serialize(ObjectWriter.java:1568)\n\tat com.fasterxml.jackson.databind.ObjectWriter.writeValue(ObjectWriter.java:1061)\n\tat org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.writeInternal(AbstractJackson2HttpMessageConverter.java:483)\n\t... 48 more\n",
    "message": "Type definition error: [simple type, class utilizingjpa.jpashop.api.OrderApiController$OrderDto]",
    "path": "/api/v2/orders"
}

 

오류의 원인은 DTO클래스에 @Data 어노테이션을 붙이지 않았기 때문이다.

 

사용자의 요청에 의해 조회된 컬렉션은, JSON 형태로 반환되는데, 스프링의 RestController는 객체를 JSON 형태로 변환할 때 Jackson 라이브러리를 사용한다. 이때 Jackson 라이브러리는 구조상 getter/setter 메소드를 사용하여 값을 읽고 변환시킨다. 하지만 현재 코드에서 작성된 OrderDto에는 @Getter 와 @Setter가 존재하지 않기 때문에 JSON 변환과정에서 위와 같은 오류가 발생한 것이다.

 

해결 방법으로 @Getter, @Setter를 붙여도 되지만 위 어노테이션을 포함하는 @Data 어노테이션을 붙여 문제를 해결 할 수 있다.

package utilizingjpa.jpashop.api;

import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import utilizingjpa.jpashop.domain.Address;
import utilizingjpa.jpashop.domain.Order;
import utilizingjpa.jpashop.domain.OrderItem;
import utilizingjpa.jpashop.domain.OrderStatus;
import utilizingjpa.jpashop.repository.OrderRepository;
import utilizingjpa.jpashop.repository.OrderSearch;

import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    // 엔티티를 직접 노출하는 경우(컬렉션 노출)
    @GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        // Lazy Loading 처리(강제 초기화)
        for (Order order : all) {
            order.getMember().getName();
            order.getDelivery().getAddress();
            // 조회하고 싶은 정보 -> orderItems
            List<OrderItem> orderItems = order.getOrderItems();
            // Lazy Loading 처리(강제 초기화)
            orderItems.stream().forEach(o -> o.getItem().getName());
        }
        return all;
    }

    // 엔티티를 DTO로 변환
    // 컬렉션 내부의 모든 컬렉션들을 DTO로 변환한 뒤 리턴(엔티티 직접 노출 x)

    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> collect = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());
        return collect;
    }

    @Data // for JSON Serialize
    static class OrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItem> orderItems;
        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems();
        }
    }

}

 

'Error' 카테고리의 다른 글

[Spring] @Configuration과 Singleton 보장 검증 오류  (0) 2024.01.12
728x90

일반적으로 서버 연동 개발을 위해 로컬 호스트 아이피(127.0.0.1)를 사용하여 로컬에서 가동중인 서버와의 통신을 시도한다.

 

하지만 이 로컬 호스트 아이피를 사용하여 아래와 같이 서버 api 호출을 요청하면 오류가 발생한다.

init{
    val gson = GsonBuilder().setLenient().create()
    val retrofit = Retrofit.Builder()
        .baseUrl("http://127.0.0.1:8080/") // 통신 오류 발생
        .addConverterFactory(GsonConverterFactory.create(gson))
        .build()
    api = retrofit.create(ApiManager::class.java)
}

 

안드로이드에서 사용하는 에뮬레이터 상의 로컬호스트 주소와 내 컴퓨터의 로컬 호스트 주소가 서로 다른 것이 원인으로

다시말해 안드로이드에서 내 컴퓨터가 아닌 가상 운영체제인 에뮬레이터 안에서 작동중인 로컬 서버로의 접속을 시도하고 있기 때문에 오류가 발생하는 것이다.

따라서 안드로이드 에뮬레이터에서 내 컴퓨터의 서버를 가리키려면 기존에 사용하던 localhost 가 아닌 별도의 주소를 사용해야 하며 종류는 아래와 같다.

10.0.2.1 라우터 또는 게이트웨이 주소
10.0.2.2 호스트 루프백 인터페이스의 특수 별칭(개발 머신의 127.0.0.1)
10.0.2.3 첫 번째 DNS 서버
10.0.2.4/10.0.2.5/10.0.2.6 두 번째, 세 번째, 네 번째 DNS 서버(선택사항)
10.0.2.15 이더넷을 사용하여 연결된 경우 에뮬레이션된 기기 네트워크
10.0.2.16 Wi-Fi를 사용하여 연결된 경우 에뮬레이션된 기기 네트워크
127.0.0.1 에뮬레이션된 기기 루프백 인터페이스

 

만약 내 컴퓨터에서 작동중인 서버가 스프링 서버라면 아래와 같은 형식으로 기존 코드를 고쳐야한다.

init{
    val gson = GsonBuilder().setLenient().create()
    val retrofit = Retrofit.Builder()
        .baseUrl("http://10.0.2.2:8080/") // 개발 머신의 127.0.0.1에 해당
        .addConverterFactory(GsonConverterFactory.create(gson))
        .build()
    api = retrofit.create(ApiManager::class.java)
}

 

추가로 안드로이드에서는 네트워크 통신시 허가된 아이피로의 통신만 허용하므로 res - xml - network_security_config.xml 에 해당 아이피를 추가해야 한다.

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
	    <!-- 개발머신의 로컬 호스트 -->
        <domain includeSubdomains="true">10.0.2.2</domain>
    </domain-config>
</network-security-config>

 

 

더 자세한 정보은 안드로이드 개발자 공식문서를 참고하길 바란다.

 

https://developer.android.com/studio/run/emulator-networking?hl=ko

 

Android Emulator 네트워킹 설정  |  Android Studio  |  Android Developers

에뮬레이터는 앱에 복잡한 모델링 및 테스트 환경을 설정하는 데 사용할 수 있는 다목적 네트워킹 기능을 제공합니다.

developer.android.com

 

728x90

1. SSL For Free에 접속한다.

https://www.sslforfree.com/

 

SSL For Free - Free SSL Certificates in Minutes

Wildcard SSL Certificates Wildcard certificates allow you to secure any sub-domains under a domain. If you want to secure any sub-domains of example.org that you have now or in the future you can make a wildcard certificate. To generate wildcard certificat

www.sslforfree.com

 

인증을 적용하고 싶은 도메인 주소를 입력한다.

우리는 퍼블릭 IPv4 DNS 주소로 진행해 볼것이다. - GET 메소드 이외에 모든 요청에서 문제없이 동작하기 위해선 탄력적 IP를 발급받아 연결하고, 탄력적 IP주소.nip.io 로 아래 과정을 진행해야한다.(게시글 수정 예정)

 

회원가입을 진행한다.

 

따로 건들 필요 없이 Next Step을 누른다.

90-Days를 선택하고 Next Step을 누른다.

 

Next Step ->

 

원하는 요금제를 선택하고 다음을 누른다.

 

2. 도메인 보유 증명

신청한 도메인의 보유자임을 증명하는 단계이다. 3가지 방법을 제시하는데 그 중 가장 마지막 방법을 선택한다.

Download Auth File을 클릭해서 파일을 다운로드 받은다음

2번에서 안내한것 처럼 프로젝트파일의 resources의 static에 .well-known/pki-validataion 디렉토리를 만들고 다운 받은 파일을 넣어준다.

 

이후 http://localhost:8080/.well-known/pki-validation/C890FF681DF87E6B522F93A1477A4385.txt 를 인터넷 창에 입력하였을때 아래와 같이 txt파일의 내용이 출력되면 정상적으로 적용한 것이다.

로컬 환경에서 성공하였으므로 이제 새로 빌드 후 배포해서 위의 3번 스텝에서 요청하는 정확한 경로로 요청이 되는걸 확인 해야 한다.

새롭게 빌드 한뒤 ec2 엔드포인트로 요청을 보냈을때도 동일한 응답이 오는지 확인한다.(수행 과정중에 인스턴스를 초기화하여 코드가 다른 것이다. 원래는 위의 사진과 동일해야 한다.)

 

과정을 수행하기 전에, 80번 포트로의 연결을 8080포트로 연결하기 위해(SSL 인증서는 HTTP로 진행되는데, 인증서가 존재하는 서버는 8080포트를 사용하기 때문)포트 포워딩을 진행해야 한다.

 

1. EC2 터미널에 접속하여 다음 명령어를 실행한다.

sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8080

 

2. 시스템에서 IP 포워딩을 활성화한다. 

cat /proc/sys/net/ipv4/ip_forward

 

위 명령어의 실행 결과가 1이 아니라면 다음 명령어를 입력하여 IP 포워딩을 활성화한다.

sudo sysctl -w net.ipv4.ip_forward=1

 

 

위 과정을 수행한 뒤, 3번에서 제시한 링크로 요청을 보내어 인증서가 출력되는지 확인한다.

http://ec2-3-39-226-146.ap-northeast-2.compute.amazonaws.com/.well-known/pki-validation/91A751775DEF8539F929F63BE14BCCB9.txt

 

인증서 코드가 정상적으로 출력된다면 성공이다.

Next Step 클릭 해 Verify Domain 을 진행한다.

 

인증에 성공하면 Tomcat을 선택하고 인증서를 다운 받는다.

 

다운받은 인증서의 압축을 해제하면 3가지 파일을 확인 할 수 있다.

 

3. 인증서 적용

다운 받은 인증서를 스프링부트에서 사용할 수 있도록 pkcs12파일을 생성해야 한다.

인증서가 존재하는 디렉토리로 이동하여 터미널에서 아래 명령어를 입력한다.

openssl pkcs12 -export -out keystore.p12 -inkey private.key -in certificate.crt -certfile ca_bundle.crt

 

비밀번호는 나중에 사용해야하니 까먹지 않도록 한다.

 

명령어를 입력하면 아래와 같이 keystore.p12 파일이 생성된다.

 

스프링 부트의 resources에 keystore 디렉토리를 만들고 그곳에 .p12파일을 저장한다.

 

keystore.p12 파일이 저장된 디렉토리에서 아래 명령어를 입력하여 상세 정보를 확인한다.

keytool -list -v -keystore ./keystore.p12 -storepass 비밀번호

 

 

application.properties에 아래 코드를 추가한다. 

keyAlias는 별칭 이름에 해당한다. 앞서 확인한 별칭인 1을 입력하였다.

# SSL
server.ssl.key-store=classpath:keystore/keystore.p12
server.ssl.key-store-password=비밀번호
server.ssl.keyStoreType=PKCS12
server.ssl.keyAlias=1

 

이후 로컬환경에서 https로 접속을 시도하면 당연하게도 오류가 발생한다.

인증서에 등록한 주소(https://ec2-...)와 일치하지 않기 때문이다.

 

고급을 눌러 세부정보를 확인해보면 정확한 사유를 알 수 있다.

 

jar 파일을 빌드후 ec2서버에서 http로 접속을 시도하면 bad request가 발생하게 되고, https로 다시 요청하게 되면 당장은 접속이 되지 않는다.

이전에 80포트를 8080으로 연결한것 처럼 443(https)도 8080포트로 연결해줘야 하기 때문이다.

터미널에 아래 명령어를 입력한다.

sudo iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 443 -j REDIRECT --to-port 8080

 

이후 https 방식으로 요청하면 정상적으로 화이트 라벨이 출력되는 것을 확인 할 수 있다.

추가로 왼쪽의 정보를 누르면 자물쇠와 함께 안전한 사이트라고 표시되는 것을 확인 할 수 있다.

 

인증에 성공하였다면 사이트에서도 직접 체크하여 인증이 성공하였음을 등록한다.

 

이전에는 http방식으로 접근이 가능했지만 이후에는 https방식으로만 접근이 가능하다. 이러한 문제를 해결하기 위해 스프링 프레임워크에 의존성을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-security' // http -> https

 

이후 시큐리티 설정 구성 클래스를 추가해준다.

@Configuration 어노테이션을 통해 스프링 프레임워크 해당 구성 파일을 등록하고 http로의 요청을 https로 리다이렉트 해준다. 이를 통해 http로 접근을 시도하면 https 접근으로 자동 변경된다.

@Configuration
public class SecurityConfig {

  @Bean
  SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
      .requiresChannel(channel -> 
          channel.anyRequest().requiresSecure())
      .authorizeRequests(authorize ->
          authorize.anyRequest().permitAll())
      .build();
    }

}

 

위 코드를 저장하고 다시 빌드한 다음 http로 접근을 시도하면 자동으로 https로 변경되는 것을 확인 할 수 있다.

이로 인해 기존에 http로 접근해왔던 사람들도 정상적으로 서비스를 이용할 수 있게 된다.

+ Recent posts