[Spring Security] 기본
여러분들이 Spring MVC를 학습하며 만들어보았던 샘플 애플리케이션은 단순하지만, Spring MVC 기반 애플리케이션에서 사용되는 웬만한 핵심 기술들은 모두 포함이 된 학습용으로 손색이 없는 꽤 괜찮은 애플리케이션입니다.
하지만 겉으로 보기에 잘 만들어진 여러분들의 샘플 애플리케이션에는 눈에 보이지 않는 가장 중요한 한 가지 영역이 빠져있습니다.
그것은 바로 보안(Security)입니다.
보안이라는 영역이 얼마나 중요한지에 대해서 보안 침해로 인한 피해를 겪어보기 전에는 개발자가 애플리케이션 구현 단계에서 보안의 중요성에 대해 현실감 있게 체감하는 건 사실 쉽지 않습니다.
‘소 잃고 외양간 고친다’라는 속담을 여러분들은 너무나도 잘 알고 있을 것입니다.
소는 외양간에서 도망간다고 하더라도 이동 반경이 크지 않기 때문에 잃어버린 소를 다시 찾아서 외양간으로 데리고 올 가능성이 상당히 높습니다.
하지만 애플리케이션(또는 소프트웨어)의 보안 침해로 인한 피해는 회복할 수 없을 정도로 클 수도 있다는 사실을 우리는 명심해야 합니다.
따라서 이번 유닛에서는 여러분들이 애플리케이션 보안의 중요성을 최대한 인식할 수 있도록 여러분들이 만든 샘플 애플리케이션에 보안 기능을 적용할 수 있는 기본 지식을 학습해 보겠습니다.
그런데 보안이라는 주제는 소프트웨어 영역에서 가장 중요한 영역이면서 또한 가장 높은 난이도를 가지는 영역 중 하나이기 때문에 여러분들이 보안에 대한 지식이나 기술을 단시간에 여러분들 것으로 만드는 것은 쉽지 않습니다.
하지만 애플리케이션 보안은 반드시 적용되어야 하는 필수 요소입니다. 따라서 굉장히 어려운 영역이긴 하지만 어떻게 해서든 여러분들이 만드는 샘플 애플리케이션에 보안을 적용해야 합니다.
어떻게 해야 할까요?
다행스럽게도 Spring 진영에는 Spring Security라는 아주 잘 만들어진 보안 프레임워크가 있습니다.
보안이라는 주제가 굉장히 어려운 주제이긴 하지만 Spring Security라는 보안 프레임워크를 이용하면 애플리케이션에 대한 대부분의 보안 취약점은 어렵지 않게 해결할 수 있습니다.
그런데 보안이라는 어려운 요소를 다루어야 하다 보니 Spring Security라는 기술 역시 만만한 기술은 아닙니다.
하지만 Spring Security 없이 여러분들이 직접 애플리케이션에 보안을 적용해야 한다면 보안에 대해서 손을 놓아버릴 가능성이 높지만, Spring Security를 사용하면 그럴 일은 없으니 안심해도 됩니다.
여러분들이 애플리케이션 보안에 손을 놓아버리는 일이 없도록 이번 시간부터 열심히 학습을 해보도록 하겠습니다.
마지막까지 잘 따라와 주기를 바라봅니다.
[Spring Security] 기본 학습을 위한 사전 준비 사항
이번 유닛의 학습을 원활하게 진행하기 위해 Spring Security가 적용되어 있지 않는 HelloSpringSecurity 애플리케이션을 위한 Controller, Service , Repository 등의 클래스들이 포함된 템플릿 프로젝트를 사용하도록 하겠습니다.
데이터 액세스 계층은 Spring Data JPA의 리포지토리로 구성이 되어 있습니다.
- 템플릿 프로젝트 복제
- 아래 github 링크에서 실습용 repository를 clone 합니다.
- IntelliJ IDE로 clone 받은 local repository 디렉토리의 프로젝트를 Open합니다.
- 학습을 진행하며 학습 내용에 따라 예제 코드를 타이핑해봅니다.
[Spring Security] 기본 학습 참고용 레퍼런스 코드
이번 유닛에서 학습한 예제 코드는 아래 github에서 확인할 수 있습니다.
챕터에서 사용한 예제 코드는 챕터에 있는 코드들을 직접 타이핑해본 후, 학습 내용을 조금 더 구체적으로 이해하기 위한 용도로만 활용해 주세요.
- Spring Security 기본 유닛에 사용한 예제 코드
학습 목표
- Spring Security를 사용해야 하는 이유를 설명할 수 있다.
- Spring Security 환경 구성을 학습하고 직접 구성할 수 있다.
- Spring Security 인증(Authentication) 구성요소에 대해 이해할 수 있다.
- Spring Security 인가(또는 권한 부여, Authorization) 구성요소에 대해 이해할 수 있다.
Chapter - Spring Security 개요
이번 챕터에서는 HelloSpringSecurity라는 샘플 애플리케이션을 만들어보면서 Spring Security가 무엇인지 Spring Security를 왜 사용해야 하는지를 이해하는 시간을 가져보겠습니다.
아울러 HelloSpringSecurity 애플리케이션을 통해 Spring Security에서 사용하는 보안에 대한 핵심 개념과 Spring Security의 동작 방식을 살펴보도록 하겠습니다.
학습 목표
- Spring Security가 무엇인지 이해할 수 있다.
- Spring Security를 사용해야 하는 이유를 알 수 있다.
- Spring Security에서 사용하는 보안에 대한 핵심 개념을 이해할 수 있다.
- Spring Security의 기본 구조와 동작 방식을 이해할 수 있다.
Spring Security란?
앞에서 언급했다시피 여러분들이 만들어 본 샘플 애플리케이션은 학습용으로 꽤 잘 만들어진 애플리케이션이지만 보안이라는 가장 중요한 요소가 전혀 고려되어 있지 않은 상태입니다.
여러분들이 만들어 본 샘플 애플리케이션이 실제로 고객이 사용하는 서비스용 애플리케이션이며, 보안을 전혀 고려하지 않은 상태로 오픈되었다고 가정해 봅시다.
이 경우, 어떤 문제가 발생할 수 있을까요?
✅ 로그인 기능(인증, Authentication)이 없음
여러분이 만든 샘플 애플리케이션은 회원 정보를 등록할 수 있지만 회원의 자격을 증명하는 인증 기능이 없습니다. 즉, 우리가 일반적으로 온라인상에서 어떤 서비스를 이용할 때 ID/패스워드 같은 인증 정보를 사용하여 자격을 증명하는 로그인 기능이 없습니다.
로그인 기능이 없다면 어떤 문제가 발생할 수 있을까요?
나 자신을 증명할 방법이 없으므로 다른 사람이 회원으로 등록한 내 정보 또는 내가 주문한 주문 정보 등에 대해서 애플리케이션의 API를 호출해서 얼마든지 조회할 수 있게 됩니다.
한마디로 애플리케이션의 상태가 우리 집 대문에 자물쇠를 채우지 않은 상태로 활짝 열어놓은 거나 마찬가지의 상태가 되는 것입니다.
이 상태로는 다른 사람의 개인정보를 탈취하는 것은 식은 죽 먹기나 마찬가지일 것입니다.
✅ API에 대한 권한 부여(인가, Authorization) 기능이 없음
애플리케이션의 서비스를 사용하기 위한 적절한 인증 절차를 거쳤다 하더라도 모든 리소스에 접근할 수 있는 것은 아닙니다.
예를 들어 단순히 커피를 주문하는 회원의 경우 커피 목록에서 커피를 조회해서 주문하고자 하는 커피를 선택한 후, 주문을 할 것입니다.
한 마디로 이 회원은 커피를 주문하는 손님입니다.
그런데 손님이 매장에서 판매하는 커피 정보를 마음대로 등록할 수 없어야 하는데 여러분이 만든 샘플 애플리케이션에서는 손님이 마음대로 커피 정보를 등록할 수 있습니다.
즉 API에 대한 접근 권한이 부여되지 않았기 때문입니다.
✅ 웹 보안 취약점에 대한 대비가 전혀 이루어지지 않았음
웹 애플리케이션을 위협하는 세션 고정 공격, 클릭재킹 공격, CSRF 등의 보안 취약점에 대한 고려가 전혀 이루어지지 않은 상태입니다.
이제 여러분들이 만든 샘플 애플리케이션에서 발생하는 보안 문제점을 해결해야 하는 시점입니다.
바로 Spring Security라는 보안 프레임워크를 이용해서 말이죠.
세션 고정 공격, 클릭재킹 공격, CSRF에서 대해서 더 알아보고 싶다면 아래의 [심화 학습]을 참고하세요.
Spring Security란?
Spring Security는 Spring MVC 기반 애플리케이션의 인증(Authentication)과 인가(Authorization or 권한 부여) 기능을 지원하는 보안 프레임워크로써, Spring MVC 기반 애플리케이션에 보안을 적용하기 위한 사실상의 표준입니다.
물론 우리가 Spring에서 지원하는 Interceptor나 Servlet Filter를 이용해서 보안 기능을 직접 구현할 수 있지만 웹 애플리케이션 보안을 대부분의 기능을 Spring Security에서 안정적으로 지원하고 있으므로 구조적으로 잘 만들어진 검증된 Spring Security를 이용하는 것이 안전한 선택이라고 볼 수 있습니다.
Spring Security로 할 수 있는 보안 강화 기능
Spring Security를 애플리케이션에 적용하면 다음과 같은 일들을 할 수 있습니다.
- 다양한 유형(폼 로그인 인증, 토큰 기반 인증, OAuth 2 기반 인증, LDAP 인증)의 사용자 인증 기능 적용
- 애플리케이션 사용자의 역할(Role)에 따른 권한 레벨 적용
- 애플리케이션에서 제공하는 리소스에 대한 접근 제어
- 민감한 정보에 대한 데이터 암호화
- SSL 적용
- 일반적으로 알려진 웹 보안 공격 차단
이 외에도 SSO, 클라이언트 인증서 기반 인증, 메서드 보안, 접근 제어 목록(Access Control List) 같은 보안을 위한 기능들을 지원합니다.
Spring Security에서 사용하는 용어 정리
Spring Security를 적용하기 위해서는 보안 영역에서 일반적으로 사용하는 개념들을 자주 접하게 됩니다.
따라서 이러한 개념들을 사전에 이해하고 있다면 Spring Security의 기능을 조금 더 쉽게 이해하고 적용할 수 있습니다.
- Principal(주체)
- Spring Security에서 사용되는 Principal은 애플리케이션에서 작업을 수행할 수 있는 사용자, 디바이스 또는 시스템 등이 될 수 있으며, 일반적으로 인증 프로세스가 성공적으로 수행된 사용자의 계정 정보를 의미합니다.
- Authentication(인증)
- Authentication은 애플리케이션을 사용하는 사용자가 본인이 맞음을 증명하는 절차를 의미합니다.
- Authentication을 정상적으로 수행하기 위해서는 사용자를 식별하기 위한 정보가 필요한데 이를 Credential(신원 증명 정보)이라고 합니다.
- 여러분들이 주민센터에 방문해서 주민등록등본을 발급받을 때를 생각해 보세요. 주민센터 직원이 여러분의 신원을 확인하기 위해 요청하는 여러분의 주민등록증이 바로 여러분의 Credential이 될 수 있습니다.
- 여러분이 특정 사이트에서 로그인을 위해 입력하는 패스워드 역시 여러분의 로그인 아이디를 증명하기 위한 Credential이 됩니다.
- Authorization(인가 또는 권한 부여)
- Authorization은 Authentication이 정상적으로 수행된 사용자에게 하나 이상의 권한(authority)을 부여하여 특정 애플리케이션의 특정 리소스에 접근할 수 있게 허가하는 과정을 의미합니다.
- Authorization은 반드시 Authentication 과정 이후 수행되어야 하며 권한은 일반적으로 역할(Role) 형태로 부여됩니다.
- Access Control(접근 제어)
- Access Control은 사용자가 애플리케이션의 리소스에 접근하는 행위를 제어하는 것을 의미합니다.
핵심 포인트
- Spring Security는 Spring MVC 기반 애플리케이션의 인증(Authentication)과 인가(Authorization or 권한 부여) 기능을 지원하는 보안 프레임워크로써, Spring MVC 기반 애플리케이션에 보안을 적용하기 위한 사실상의 표준이다.
- Principal(주체)은 일반적으로 인증 프로세스가 성공적으로 수행된 사용자의 계정 정보를 의미한다.
- Authentication(인증)은 애플리케이션을 사용하는 사용자가 본인이 맞음을 증명하는 절차를 의미한다.
- Authorization(인가 또는 권한 부여)은 Authentication이 정상적으로 수행된 사용자에게 하나 이상의 권한(authority)을 부여하여 특정 애플리케이션의 특정 리소스에 접근할 수 있게 허가하는 과정을 의미한다.
- Credential(신원 증명 정보)은 Authentication을 정상적으로 수행하기 위해서는 사용자를 식별하기 위한 정보를 의미한다.
- Access Control(접근 제어)은 사용자가 애플리케이션의 리소스에 접근하는 행위를 제어하는 것을 의미한다.
심화 학습
- 세션 고정(session fixation) 공격에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- 클릭재킹 공격에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- CSRF에서 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
Spring Security를 사용해야 하는 이유
✅ 보안이 더 어려울까?, Spring Security를 사용하기가 더 어려울까?
우리가 Spring Security를 사용해야 하는 이유에 대해서 간단하게 짚고 넘어가 봅시다.
“나는 Spring Security를 사용하지 않고, 보안 강화를 위한 솔루션을 직접 개발해서 구축할 거야”
이렇게 생각하는 분들이 분명히 전 세계 어딘가에 존재할 수 있습니다.
Spring Security를 사용해 본 사람들이 항상 하는 이야기는 ‘어렵다’입니다.
Spring Security가 어렵게 느껴지는 가장 큰 이유는 무엇일까요?
바로 보안이라는 주제 자체가 소프트웨어 세계에서 어려운 주제 중 하나이기 때문입니다.
그렇다면 Spring Security를 사용하기가 어려울까요, 아니면 그 어려운 보안을 강화하기 위해 밑바닥부터 모두 설계하고 구현하기가 어려울까요?
판단은 여러분들의 몫으로 남겨두겠습니다.
✅ Spring Security를 사용해야 하는 이유
그런데 애플리케이션의 보안을 강화하기 위해 왜 굳이 Spring Security를 사용해야 할까요?
결론부터 이야기하자면 애플리케이션의 보안을 강화하기 위한 솔루션으로 Spring Security 만 한 다른 프레임워크가 존재하지 않기 때문입니다.
물론 Apache Shiro, OACC 같은 Java 애플리케이션을 위한 보안 프레임워크가 존재하지만, Spring Security는 다른 보안 프레임워크가 제공하는 기능들을 모두 아우르는 기능을 지원하고 있으며, 또한 Spring 기반의 애플리케이션을 구현하는 개발자로서는 Spring과 궁합이 가장 잘 맞는 Spring Security를 사용하는 것은 자연스러운 일이라고 볼 수 있습니다.
또한 Spring Security를 사용하면 Spring Security에서 지원하는 기본 옵션을 통해 대부분의 보안 요구 사항을 만족시킬 수 있습니다.
그런데 때로는 기본 옵션으로 만족시킬 수 없는 특정 보안 요구 사항을 만족시켜야 할 경우가 있는데, 이 경우 Spring Security를 사용하면 특정 보안 요구 사항을 만족시키기 위한 코드의 커스터마이징이 용이하고 유연한 확장이 가능합니다.
이제 여러분이 Spring Security를 사용해야 하는 이유는 공감할 것으로 생각합니다.
물론 사용해보지 않으면 명확한 이유를 알 수 없지만 개념적으로는 명확해졌을 거라 믿겠습니다.
여러분들이 Spring Security를 사용해야 하는 이유에 대해 더 공감할 수 있도록 이어지는 챕터에서 바로 Spring Security를 사용해 봅시다.
핵심 포인트
- 보안 기능을 밑바닥부터 직접 구현하는 것보다 잘 검증되어 신뢰할 만한 Spring Security를 사용하는 것이 더 나은 선택이다.
- Spring Security는 특정 보안 요구 사항을 만족하기 위한 커스터마이징이 용이하고, 유연한 확장이 가능하다.
심화 학습
- Apache Shiro에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- OACC에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
[기본] Hello, Spring Security로 알아보는 Spring Security의 기본 구조 (1)
새로운 프로그래밍 언어나 기술을 처음 학습할 때 제일 먼저 등장하는 ‘Hello, World’를 Spring Security에서 만날 볼 시간이 찾아왔습니다.
프로그래밍 언어 또는 기술에서 일반적으로 의미하는 Hello, World는 대부분이 “Hello World”라는 단순한 문자열을 출력해 보면서 해당. 프로그래밍 언어나 기술의 특징, 동작 방식 등을 가볍게 파악해 보는 아주 단순한 프로그램입니다.
그런데 애플리케이션 보안을 위한 기술, 즉 우리가 이번 시간부터 학습하게 될 Spring Security는 일반적인 Hello World 프로그램과는 조금 다를 수밖에 없습니다.
보안은 직접적으로 눈으로 확인해 볼 수 있는 기술이 아니니까요.
따라서 이번 시간에 학습할 Hello Spring Security는 간단한 애플리케이션에 아주 최소한의 Spring Security라는 기술을 적용해서 Spring Security의 구조와 동작 방식을 가볍게 알아보면서 Spring Security에 대한 감을 잡아본다는 의미로 받아들이면 되겠습니다.
Hello Spring Security 샘플 애플리케이션
여러분들이 이제껏 학습했던 백엔드 애플리케이션의 구현 방식은 SSR(Server Side Rendering) 방식이 아닌 CSR(Client Side Rendering) 방식입니다.
즉, 여러분들은 프론트엔드와 백엔드가 분리된 구조의 백엔드 개발자가 되기 위한 학습을 하고 있는 것으로 볼 수 있습니다.
하지만 이번 시간만큼은 SSR(Server Side Rendering) 방식으로 구현된 Hello Spring Security 샘플 애플리케이션을 이용하도록 하겠습니다.
Spring Security의 기본 구조와 동작 방식을 이해하기 가장 쉬운 방식이 바로 서버에서 HTML을 만들어 클라이언트 쪽으로 내려주는 SSR(Server Side Rendering) 방식이기 때문입니다.
SSR(Server Side Rendering) 방식의 애플리케이션은 세션 기반의 폼 로그인 방식을 적용하기 가장 적합한 애플리케이션이며, 또한 폼 로그인 방식은 Spring Security에 처음 입문하는 입문자들이 Spring Security를 이해하기에 가장 적합한 인증 방식입니다. ⭐ CSR(Client Side Rendering) 방식의 백엔드 애플리케이션에 대한 학습을 진행하는 여러분들이 이번 시간을 제외하고 남아 있는 기간 동안 폼 로그인 방식을 사용할 일은 없다는 사실을 염두에 두고, Spring Security의 기본 구조와 동작 방식을 이해하는 데 집중해 주세요!
💡⭐ Hello Spring Security 샘플 애플리케이션의 학습은 Spring Security 학습에 집중하기 위해 템플릿 프로젝트로 학습을 진행해 주세요!
✅ Hello Spring Security 샘플 애플리케이션의 구조
여러분들이 이번 유닛의 [개요 및 사전 준비]에서 템플릿 프로젝트를 실행시킨 후, 웹 브라우저에서 http://localhost:8080 URL로 접속하면 아래의 [그림 4-1]과 같은 화면을 확인할 수 있습니다.
✔ Hello Spring Security 샘플 애플리케이션의 홈 화면
[그림 4-1] Hello Spring Security 샘플 애플리케이션의 홈 화면
[그림 4-1]은 Hello Spring Security 샘플 애플리케이션의 홈 화면입니다.
UI(User Experience)는 형편없지만 우리의 관심사는 UI가 아니라 Spring Security라는 사실을 기억하면서 ‘예쁜 화면이구나’라고 생각하며 봐주기를 바랍니다.
Hello Spring Security 샘플 애플리케이션은 Spring Security의 기본 구조와 동작 방식을 살펴보기 위한 정말 최소한의 기능만 포함하고 있습니다.
앞에서 우리가 함께 학습하면서 만들어 본 커피 주문 샘플 애플리케이션의 SSR(Server Side Rendering) 버전이라고 보아도 무방할 정도로 여러분들에게 익숙한 기능이라고 생각합니다.
✔ 회원 가입 화면
[그림 4-2] Hello Spring Security 샘플 애플리케이션의 회원 가입 화면
[그림 4-2]는 샘플 애플리케이션의 회원 등록을 위한 회원 가입 화면입니다.
역시 최소한의 정보만 입력하도록 구성했습니다.
<!DOCTYPE html>
<html xmlns:th="<http://www.thymeleaf.org>"
xmlns:layout="<http://www.ultraq.net.nz/thymeleaf/layout>"
layout:decorate="layouts/common-layout">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Hello Spring Security Coffee Shop</title>
</head>
<body>
<hr />
<div class="container" layout:fragment="content">
<!-- (1) 회원 가입 폼 -->
<form action="/members/register" method="post">
<div class="row">
<div class="col-xs-2">
<input type="text" name="fullName" class="form-control" placeholder="User Name"/>
</div>
</div>
<div class="row" style="margin-top: 20px">
<div class="col-xs-2">
<input type="email" name="email" class="form-control" placeholder="Email"/>
</div>
</div>
<div class="row" style="margin-top: 20px">
<div class="col-xs-2">
<input type="password" name="password" class="form-control" placeholder="Password"/>
</div>
</div>
<button class="btn btn-outline-secondary" style="margin-top: 20px">회원 가입</button>
</form>
</div>
</body>
</html>
[코드 4-1] 회원 가입 HTML 폼
코드 4-1은 [그림 4-2]와 같이 회원 가입 화면을 구성하는 HTML 코드입니다.
(1)의 HTML form 태그를 이용해서 회원 가입 폼을 구성하며 해당 폼에서 입력한 정보는 아래의 코드 4-2에서 MemberController의 registerMember() 핸들러 메서드를 통해 데이터베이스에 저장됩니다.
package com.springboot.member;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@Controller
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
private final MemberMapper mapper;
...
...
// (1)
@PostMapping("/register")
public String registerMember(@Valid MemberDto.Post requestBody) {
Member member = mapper.memberPostToMember(requestBody);
memberService.createMember(member);
System.out.println("Member Registration Successfully");
return "login";
}
}
[코드 4-2] MemberController 코드 일부
(1)의 registerMember() 핸들러 메서드를 통해 회원 가입 폼에서 전송한 회원 정보가 우리가 잘 알고 있는 서비스 계층과 데이터 액세스 계층을 거쳐서 데이터베이스에 저장됩니다.
💡 코드 4-2에서 사용된 MemberService와 MemberMapper는 여러분들에게 제공한 템플릿 프로젝트의 코드를 참고해 주세요!
코드 4-2의 MemberController 클래스는 JSON 응답 데이터를 클라이언트 쪽에 response body로 전송하는 것이 아니라 HTML을 렌더링 해서 클라이언트 쪽에 통째로 내려주는 SSR(Server Side Rendering) 방식의 코드로 작성되어 있습니다.
여러분들이 SSR 방식의 코드 구현 방식을 몰라도 되지만 어떤 방식으로 구현하는지 더 알아보고 싶은 분들은 아래의 [심화 학습]을 참고하기 바랍니다.
✔ 로그인 화면
[그림 4-3] Hello Spring Security 샘플 애플리케이션의 로그인 화면
[그림 4-3]은 샘플 애플리케이션의 로그인 화면입니다.
역시 굉장히 심플한 화면이며, 로그인 인증을 위해 이메일과 패스워드를 입력할 수 있습니다.
로그인 화면 역시 HTML form 태그로 구성이 되어 있으며, HTML의 form 방식으로 로그인 인증을 진행하기 때문에 Spring Security에서도 이러한 인증 방식을 폼 로그인 인증이라고 부릅니다.
⭐ [그림 4-3]의 로그인 화면은 Spring Security 보안 구성에서 커스텀 로그인 페이지 설정을 하기 전까지는 직접적인 기능을 하지 않습니다. 자세한 내용은 아래에서 계속 설명합니다.
✔ 커피 보기 화면
[그림 4-4] Hello Spring Security 샘플 애플리케이션의 ‘커피 보기’ 화면
커피 보기 화면은 다른 화면보다 더 심플하군요.
우리의 관심사가 ‘커피 보기’ 화면에 보이는 커피 목록은 아니기 때문에 화면에 구체적인 콘텐츠는 없어도 됩니다.
단지 Spring Security를 적용해 모든 사용자가 접근할 수 있는 화면이 되도록 설정할 수 있기만 하면 되니깐요. 😊
✔ 전체 주문 목록 보기 화면
[그림 4-5] Hello Spring Security 샘플 애플리케이션의 ‘전체 주문 목록 보기’ 화면
[그림 4-5]의 ‘전체 주문 목록 보기’ 화면 역시 ‘커피 보기’ 화면과 마찬가지의 용도입니다.
여기서는 Spring Security를 적용했을 때, 관리자만 접근할 수 있는 페이지로 설정할 수 있기만 하면 됩니다.
✔ 마이페이지 화면
[그림 4-6] Hello Spring Security 샘플 애플리케이션의 ‘마이페이지’ 화면
[그림 4-6]의 ‘마이페이지’ 화면은 Spring Security를 적용했을 때, 일반 사용자만 접근할 수 있는 페이지입니다.
✅ Hello Spring Security 샘플 애플리케이션의 문제점
여러분들에게 제공한 템플릿 프로젝트의 Hello Spring Security 샘플 애플리케이션은 아래의 [코드 4-3]과 같이 현재 로그인 기능이 구체적으로 구현되어 있지 않습니다.
package com.springboot.auth;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/auths")
public class AuthController {
@GetMapping("/login-form")
public String loginForm() {
return "login";
}
@GetMapping("/access-denied")
public String accessDenied() {
return "access-denied";
}
// (1)
@PostMapping("/login")
public String login() {
System.out.println("Login successfully!");
return "home";
}
}
[코드 4-3] 로그인 인증을 위한 AuthController
[그림 4-3]의 로그인 폼에서 [로그인] 버튼을 클릭하면 (1)의 login() 핸들러 메서드로 요청이 전송됩니다.
하지만 코드상에는 현재 로그인을 위한 인증 처리 없이 home 화면으로 이동하도록 구성된 상태입니다.
로그인 인증이 정상적으로 이루어지지 않기 때문에 현재 상태에서는 커피를 주문하는 회원이든, 매장에서 커피를 만들어서 제공하는 카페 관계자이든 상관없이 모든 화면에 자유롭게 접근할 수 있는 문제점을 가지고 있습니다.
이제 이런 문제점을 해결하기 위해 Spring Security를 적용해 보면서 Spring Security의 기본 구조와 동작 방식을 살펴보도록 하겠습니다.
Hello Spring Security 샘플 애플리케이션은 SSR(Server Side Rendering) 방식의 애플리케이션이기 때문에 클라이언트에게 전송하는 HTML 코드까지 포함하고 있으며, 이러한 HTML 뷰를 구성하기 위해 **타임리프(Thymeleaf)**라는 템플릿 엔진을 사용하고 있습니다.
여러분들이 Spring Security의 학습에 집중할 수 있도록 템플릿 코드 또는 레퍼런스 코드에 뷰 영역의 코드들이 사전에 구성되어 있으니, 해당 코드들이 궁금한 분들은 아래 경로의 xxxx.html 파일을 확인하세요.
타임리프로 구성된 HTML 템플릿 파일 경로: src/main/resources/templates
✅ Hello Spring Security 샘플 애플리케이션에 Spring Security 적용
✔ 의존 라이브러리 추가(build.gradle)
plugins {
id 'org.springframework.boot' version '2.7.2'
id 'io.spring.dependency-management' version '1.0.12.RELEASE'
id 'java'
}
group = 'com.springboot'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
repositories {
mavenCentral()
}
dependencies {
...
...
implementation 'org.springframework.boot:spring-boot-starter-security' // (1)
...
...
}
tasks.named('test') {
useJUnitPlatform()
}
[코드 4-4] build.gradle에 spring-boot-starter-security 추가
Spring Boot 기반의 애플리케이션에 Spring Security를 적용하기 위해서는 코드 4-4의 (1)과 같이 spring-boot-starter-security 스타터를 추가하면 됩니다.
이제 Spring Security의 의존 라이브러리와 기본 자동 구성이 된 상태에서 애플리케이션을 실행한 후, 웹 브라우저에서 다시 http://localhost:8080으로 접속해 보면 아래의 [그림 4-6]과 같은 로그인 화면으로 리다이렉트 되는 것을 확인할 수 있습니다.
[그림 4-6] Spring Security 자동 구성 적용 후, 리다이렉트 되는 로그인 화면
[그림 4-6]은 우리가 앞에서 직접 만든 로그인 페이지가 아닙니다.
저희는 이렇게 예쁘게 만들지 않았습니다. 누가 만들어 주었을까요?
바로 Spring Security의 자동 구성을 통해 Spring Security가 내부적으로 제공해 주는 디폴트 로그인 페이지입니다.
그런데 우리가 별도로 등록한 회원이 없는데, 로그인하려면 Username과 Password 정보로 무얼 입력해야 하는 걸까요?
이 역시도 Spring Security에서 디폴트로 제공해 주는 정보가 있습니다.
✔ Spring Security에서 제공해 주는 디폴트 로그인 정보
- Username: ‘user’를 입력하면 됩니다.
- Password: 여러분들이 애플리케이션을 실행할 때마다 출력되는 로그에서 확인할 수 있습니다.
오후 5:23:00: Executing ':HelloSpringSecurityApplication.main()'...
> Task :compileJava UP-TO-DATE
> Task :processResources UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :HelloSpringSecurityApplication.main()
. ____ _ __ _ _
/\\\\ / ___'_ __ _ _(_)_ __ __ _ \\ \\ \\ \\
( ( )\\___ | '_ | '_| | '_ \\/ _` | \\ \\ \\ \\
\\\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.7.2)
2022-08-19 17:23:02.694 INFO 14752 --- [ main] c.c.HelloSpringSecurityApplication : Starting HelloSpringSecurityApplication using Java 11.0.1 on hjs6877 with PID 14752 (D:\\springboot\\project\\kdt\\for-ese\\section4-week1-hello-spring-security\\build\\classes\\java\\main started by hjs68 in D:\\springboot\\project\\kdt\\for-ese\\section4-week1-hello-spring-security)
2022-08-19 17:23:02.696 INFO 14752 --- [ main] c.c.HelloSpringSecurityApplication : No active profile set, falling back to 1 default profile: "default"
2022-08-19 17:23:03.660 INFO 14752 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2022-08-19 17:23:03.737 INFO 14752 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 68 ms. Found 1 JPA repository interfaces.
2022-08-19 17:23:07.037 DEBUG 14752 --- [ main] tor$SharedEntityManagerInvocationHandler : Creating new EntityManager for shared EntityManager invocation
2022-08-19 17:23:07.177 WARN 14752 --- [ main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2022-08-19 17:23:07.653 WARN 14752 --- [ main] .s.s.UserDetailsServiceAutoConfiguration :
...
...
Using generated security password: **32593900-2c36-4cdb-9d15-ce8e5049481f** // (1)
This generated password is for development use only. Your security configuration must be updated before running your application in production.
...
...
2022-08-19 17:23:07.791 INFO 14752 --- [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@7866fe3e, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@498f1f63, org.springframework.security.web.context.SecurityContextPersistenceFilter@1529d534, org.springframework.security.web.header.HeaderWriterFilter@598ff2b3, org.springframework.security.web.csrf.CsrfFilter@5b1cedfd, org.springframework.security.web.authentication.logout.LogoutFilter@519a81dd, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@6bc08a77, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@52a605c3, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@6250d33c, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@4d2f8ee7, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@3c5cb013, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@5f69462f, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@2a7b4bb6, org.springframework.security.web.session.SessionManagementFilter@67cd193d, org.springframework.security.web.access.ExceptionTranslationFilter@5aa5b3af, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@58701e8c]
2022-08-19 17:23:07.865 INFO 14752 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
...
...
샘플 애플리케이션을 실행할 때마다 (1)과 같이 생성되는 임시 패스워드를 이용해서 샘플 애플리케이션에 로그인할 수 있습니다.
그리고 잘못된 Username 또는 Password를 입력해서 \[Sign in] 버튼을 눌러보면 아래의 [그림 4-7]과 같이 인증 실패에 대해 친절한 안내까지 해주는 것을 볼 수 있습니다.
[그림 4-7] 디폴트 Username/Password가 아닌 인증 정보를 입력할 경우
그런데, 이 방식은 실무에서 사용하기에는 좀 무리가 있어 보이죠?
애플리케이션을 매번 실행할 때마다 패스워드가 바뀌는 것도 문제이며, Spring Security에서 제공하는 디폴트 인증 정보만으로는 회원 각자의 인증 정보로 로그인을 하는 것 역시 사실상 불가능합니다.
그리고 우리가 앞에서 직접 작성해 둔 로그인 페이지 역시 사용할 수 없습니다.
어떻게 해야 할까요?
Spring Security의 Configuration을 통해 우리의 입맛에 맞는 인증 방식을 설정하면 됩니다.
⭐ Spring Security Configuration 적용
Spring Security Configuration을 적용하면 우리가 원하는 인증 방식과 웹 페이지에 대한 접근 권한을 설정할 수 있습니다.
지금부터 우리가 원하는 대로 Spring Security의 기능을 단계적으로 적용해 봅시다.
1️⃣ Spring Security Configuration의 기본 구조
package com.springboot.config;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SecurityConfiguration {
// 여기에 Spring Security의 설정을 진행합니다.
}
[코드 4-5] Spring Security Configuration의 기본 구조
Spring Boot에서 Spring Security Configuration을 위한 기본 구조는 굉장히 심플합니다.
바로 @Configuration 애너테이션 하나만 추가해 주는 것이 전부이니까요.
이제 이 SecurityConfiguration 클래스에 Spring Security에서 지원하는 인증과 권한 부여 설정을 하면 됩니다.
2️⃣ InMemory User로 인증하기
package com.springboot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class SecurityConfiguration {
@Bean
public UserDetailsManager userDetailsService() {
// (1)
UserDetails userDetails =
User.withDefaultPasswordEncoder() // (1-1)
.username("kevin@gmail.com") // (1-2)
.password("1111") // (1-3)
.roles("USER") // (1-4)
.build();
// (2)
return new InMemoryUserDetailsManager(userDetails);
}
}
[코드 4-6] InMemory Single User 인증 정보 설정
코드 4-6은 애플리케이션이 실행된 상태에서 사용자 인증을 위한 계정 정보를 메모리상에 고정된 값으로 설정한 예입니다.
우리가 앞에서 Spring Security의 기본 설정을 사용할 때마다 매번 애플리케이션이 실행될 때마다 랜덤 하게 생성되는 패스워드를 이용해야 했던 반면에 코드 4-6에서는 사용자의 계정 정보를 메모리상에 지정했기 때문에 애플리케이션이 실행될 때마다 사용자 계정 정보가 바뀔 일은 없습니다.
✔ 코드 4-6의 코드 설명
- (1)의 UserDetails 인터페이스는 인증된 사용자의 핵심 정보를 포함하고 있으며, UserDetails 구현체인 (2)의 User 클래스를 이용해서 사용자의 인증 정보를 생성하고 있습니다.
- withDefaultPasswordEncoder()는 디폴트 패스워드 인코더를 이용해 사용자 패스워드를 암호화합니다. 예제 코드상에서는 (1-3)의 password() 메서드의 파라미터로 전달한 “1111”을 암호화해 줍니다.
- username() 메서드는 사용자의 usrname을 설정합니다. 여러분들이 헷갈릴까 봐 노파심에 하는 말이지만 여기서 의미하는 username은 “Kevin”, “Tom” 같은 사람의 이름을 의미하는 게 아니라 고유한 사용자를 식별할 수 있는 사용자 아이디 같은 값입니다.
- password() 메서드는 사용자의 password를 설정합니다. 파라미터로 지정한 값은 (1-1)의 withDefaultPasswordEncoder()로 인해 암호화됩니다.
- roles() 메서드는 사용자의 Role 즉, 역할을 지정하는 메서드입니다.
- Spring Security에서는 사용자의 핵심 정보를 포함한 UserDetails를 관리하는 UserDetailsManager라는 인터페이스를 제공합니다.(2)와 같이 new InMemoryUserDetailsManager(userDetails)를 통해 UserDetailsManager 객체를 Bean으로 등록하면 Spring에서는 해당 Bean이 가지고 있는 사용자의 인증 정보가 클라이언트의 요청으로 넘어올 경우 정상적인 인증 프로세스를 수행합니다.
- 그런데 우리는 메모리상에서 UserDetails를 관리하므로 InMemoryUserDetailsManager라는 구현체를 사용합니다.
이제 애플리케이션을 다시 실행하고, [로그인] 화면에서 코드 4-6에 추가한 username과 password 정보로 로그인해 보면 정상적으로 로그인에 성공하는 것을 확인할 수 있습니다.
그런데, 실제 서비스에서 이런 식으로 사용자 계정 정보를 고정시켜 사용하지는 않을 겁니다.
이 방식은 테스트 환경 또는 데모 환경에서만 유용하게 사용할 수 있는 방식임을 참고하길 바랍니다.
실무에서는 사용자 계정 정보가 데이터베이스에 저장되겠지만 이 부분은 뒤에서 다시 설명하겠습니다.
Deprecated 상태인 withDefaultPasswordEncoder()
API 문서에서 흔히 볼 수 있는 Deprecated라는 용어는 해당 API가 향후 버전에서는 더 이상 사용되지 않고 제거될 수 있다는 의미이기 때문에 Deprecated라고 표시된 API 사용은 권장되지 않습니다.
⭐ 그런데 withDefaultPasswordEncoder() 메서드의 Deprecated는 특이하게도 향후 버전에서 제거됨을 의미하기보다는 Production 환경에서 인증을 위한 사용자 정보를 고정해서 사용하지 말라는 경고의 의미를 나타내고 있는 것이니 반드시 테스트 환경이나 데모 환경에서만 사용하기 바랍니다.
결론은 우리는 학습을 위한 용도이므로 사용해도 됩니다.
3️⃣ HTTP 보안 구성 기본
@Configuration
public class SecurityConfiguration {
// (1)
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// HttpSecurity를 통해 HTTP 요청에 대한 보안 설정을 구성한다.
...
...
}
@Bean
public InMemoryUserDetailsManager userDetailsService() {
UserDetails user =
User.withDefaultPasswordEncoder()
.username("kevin@gmail.com")
.password("1111")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
[코드 4-7] Spring Security의 HTTP 보안 설정 기본
코드 4-7은 Spring Security에서 HTTP 보안을 설정하기 위한 기본 코드입니다.
(1)과 같이 HttpSecurity를 파라미터로 가지고, SecurityFilterChain을 리턴하는 형태의 메서드를 정의하면 HTTP 보안 설정을 구성할 수 있습니다.
파라미터로 지정한 HttpSecurity는 HTTP 요청에 대한 보안 설정을 구성하기 위한 핵심 클래스입니다.
Spring Security 5.7 이전 버전에서는 HTTP 보안 설정을 구성하기 위해 WebSecurityConfigurerAdapter를 상속하는 형태의 방법을 주로 사용했지만 WebSecurityConfigurerAdapter는 5.7.0에서 Deprecated 되었습니다.
따라서 코드 4-7과 같이 SecurityFilterChain을 Bean으로 등록해서 HTTP 보안 설정을 구성하는 방식을 권장한다는 사실을 기억하기 바랍니다.
4️⃣ 커스텀 로그인 페이지 지정하기
앞에서 우리가 로그인을 위해 사용한 로그인 페이지는 Spring Security에서 내부적으로 제공하는 로그인 페이지입니다.
이제 우리가 만들어 두었던 [그림 4-3]의 로그인 페이지를 사용하도록 코드 4-7의 기본 구성을 기반으로 설정을 추가해 보도록 하겠습니다.
package com.springboot.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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable() // (1)
.formLogin() // (2)
.loginPage("/auths/login-form") // (3)
.loginProcessingUrl("/process_login") // (4)
.failureUrl("/auths/login-form?error") // (5)
.and() // (6)
.authorizeHttpRequests() // (7)
.anyRequest() // (8)
.permitAll(); // (9)
return http.build();
}
@Bean
public InMemoryUserDetailsManager userDetailsService() {
UserDetails user =
User.withDefaultPasswordEncoder()
.username("kevin@gmail.com")
.password("1111")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
[코드 4-8] 커스텀 로그인 페이지 설정
코드 4-8에서는 Spring Security의 보안 구성 중에서 우리가 만들어 둔 커스텀 로그인 페이지를 사용하기 위한 최소한의 설정만 추가한 코드입니다.
설정한 내용이 어떤 기능을 하는지 차근차근 확인해 봅시다.
- (1)에서는 CSRF(Cross-Site Request Forgery) 공격에 대한 Spring Security에 대한 설정을 비활성화하고 있습니다.우리는 로컬 환경에서 Spring Security에 대한 학습을 진행하므로, CSRF 공격에 대한 설정이 필요하지 않습니다.
- 만약, csrf().disable() 설정을 하지 않는다면 403 에러로 인해 정상적인 접속이 불가능합니다.
- Spring Security는 기본적으로 아무 설정을 하지 않으면 csrf() 공격을 방지하기 위해 클라이언트로부터 CSRF Token을 수신 후, 검증합니다.
CSRF 공격에 대한 학습 내용은 아래의 [인증/보안] 기초 유닛 링크를 통해 확인 바랍니다.
- (2)의 formLogin()을 통해 기본적인 인증 방법을 폼 로그인 방식으로 지정합니다.
- (3)의 loginPage("/auths/login-form") 메서드를 통해 우리가 템플릿 프로젝트에서 미리 만들어 둔 커스텀 로그인 페이지를 사용하도록 설정합니다.
- loginPage()의 파라미터인 "/auths/login-form"은 AuthController의 loginForm() 핸들러 메서드에 요청을 전송하는 요청 URL입니다.
- (4)의 loginProcessingUrl("/process_login") 메서드를 통해 로그인 인증 요청을 수행할 요청 URL을 지정합니다.
- loginProcessingUrl()의 파라미터인 "/process_login"은 우리가 만들어 둔 login.html에서 form 태그의 action 속성에 지정한 URL과 동일합니다.
<!DOCTYPE html>
<html xmlns:th="<http://www.thymeleaf.org>"
xmlns:layout="<http://www.ultraq.net.nz/thymeleaf/layout>"
layout:decorate="layouts/common-layout">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Hello Spring Security Coffee Shop</title>
</head>
<body>
<div layout:fragment="content">
<form action="**/process_login**" method="post">
<p><input type="email" name="username" placeholder="Email" /></p>
<p><input type="password" name="password" placeholder="Password" /></p>
<p><button>로그인</button></p>
</form>
<a href="/members/register">회원가입</a>
</div>
</body>
</html>
[코드 4-9] 커스텀 로그인 페이지(login.html)
코드 4-9는 우리가 템플릿 프로젝트에 작성해 둔 login.html의 코드입니다.
form 태그를 보면 <form action="**/process_login**" method="post"> 같이 /process_login URL이 지정된 것을 확인할 수 있습니다.
커스텀 로그인 화면에서 [로그인] 버튼을 클릭하게 되면 form 태그의 action 속성에 지정된 /process_login URL로 사용자 인증을 위한 email 주소와 패스워드를 전송하게 됩니다.
여기서 한 가지 의문이 들 수 있는 게 /process_login URL로 요청을 전송하면 해당 요청은 누가 처리를 할까요?
이번 챕터의 앞에서 잠깐 언급했지만, 아직 우리는 AutoController에서 별도의 인증 프로세스 로직을 작성하지 않았습니다.
결국 /process_login URL로 요청을 전송하면 여전히 Spring Security에서 내부적으로 인증 프로세스를 진행합니다.
⭐ 결국 현재 상태에서는 login.html 같은 커스텀 로그인 페이지를 통해 Spring Security가 로그인 인증 처리를 하기 위한 요청 URL을 지정하는 것이라는 사실을 기억하길 바랍니다.
- (5)의 failureUrl("/auths/login-form?error") 메서드를 통해 로그인 인증에 실패할 경우 어떤 화면으로 리다이렉트 할 것인가를 지정합니다.
- 로그인에 실패할 경우, 로그인 화면을 표시하고 로그인 인증에 실패했다는 메시지를 표시해 주는 게 가장 자연스러울 것이므로 failureUrl()의 파라미터로 커스터 로그인 페이지의 URL인 "/auths/login-form?error"을 지정합니다.
<!DOCTYPE html>
<html xmlns:th="<http://www.thymeleaf.org>"
xmlns:layout="<http://www.ultraq.net.nz/thymeleaf/layout>"
layout:decorate="layouts/common-layout">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Hello Spring Security Coffee Shop</title>
</head>
<body>
<div class="container" layout:fragment="content">
<form action="/process_login" method="post">
<!-- (1) 로그인 실패에 대한 메시지 표시 -->
<div class="row alert alert-danger center" role="alert" th:if="${param.error != null}">
<div>로그인 인증에 실패했습니다.</div>
</div>
<div class="row">
<div class="col-xs-2">
<input type="email" name="username" class="form-control" placeholder="Email" />
</div>
</div>
<div class="row" style="margin-top: 20px">
<div class="col-xs-2">
<input type="password" name="password" class="form-control" placeholder="Password" />
</div>
</div>
<button class="btn btn-outline-secondary" style="margin-top: 20px">로그인</button>
</form>
<div style="margin-top: 20px">
<a href="/members/register">회원가입</a>
</div>
</div>
</body>
</html>
[코드 4-10] 인증 실패 메시지 표시 로직을 추가한 커스텀 로그인 페이지(login.html)
코드 4-10은 로그인 인증에 실패할 경우, 인증에 실패했다는 메시지를 표시하기 위한 로직을 추가한 login.html 코드입니다.
코드 4-10의 (1)을 보면, ${param.error}의 값을 통해 로그인 인증 실패 메시지 표시 여부를 결정하고 있습니다.
${param.error}는 Spring Security Configuration에서 failureUrl("/auths/login-form?error")의 ?error 부분에 해당하는 쿼리 파라미터를 의미합니다.
[그림 4-8] 로그인 인증에 실패할 경우의 커스텀 로그인 페이지
[그림 4-8]은 로그인 인증에 실패할 경우 인증 실패 메시지를 표시하는 기능이 추가된 커스텀 로그인 페이지 화면입니다.
커스텀 로그인 페이지에서 인증 정보로 사용되는 email과 password는 무얼 입력해야 로그인 인증에 성공하는 걸까요? 혹시나 여러분들이 잊고 있을까 봐 노파심에 하는 이야기입니다.
우리가 SecurityConfiguration의 [HTTP 보안 구성 기본]에서 설정한 “kevin@gmail.com”과 “1111”이 로그인 인증에 성공하는 계정 정보입니다.
그 외의 계정 정보를 입력할 경우 로그인 화면에서 인증 실패 메시지를 확인할 수 있을 것입니다.
다시 코드 4-8의 Spring Security 설정에 대한 설명으로 돌아가서,
- (6)의 and() 메서드를 통해 Spring Security 보안 설정을 메서드 체인 형태로 구성할 수 있습니다.
- (7), (8), (9)를 통해서 클라이언트의 요청에 대해 접근 권한을 확인합니다. 접근을 허용할지 여부를 결정합니다.
- (7)의 authorizeHttpRequests() 메서드를 통해 클라이언트의 요청이 들어오면 접근 권한을 확인하겠다고 정의합니다.
- (8)과 (9)의 anyRequest().permitAll() 메서드를 통해 클라이언트의 모든 요청에 대해 접근을 허용합니다.
여러분들의 이해를 돕기 위해 당장에는 로그인 인증 여부, 접근 권한 부여 여부와 상관없이 Hello Spring Security 샘플 애플리케이션의 모든 페이지에 대한 접근을 허용했습니다.
이 부분은 뒤에서 접근 권한 별로 페이지 접근을 제한해 보도록 하겠습니다.
5️⃣ request URI에 접근 권한 부여
여러분들이 여기까지 정상적으로 타이핑을 했다면 SecurityConfiguration 클래스에서 설정해 둔 사용자 계정(kevin@gmail.com)을 통해 로그인 인증에 성공할 것입니다.
그럼, 이제 사용자에게 부여된 Role을 이용해서 샘플 애플리케이션의 request URI에 접근 권한을 부여해 보도록 하겠습니다.
SecurityConfiguration 클래스에서 설정해 둔 사용자 계정 정보는 다음과 같습니다.
- email: kevin@gmail.com
- password: 1111
- Role: USER
package com.springboot.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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.formLogin()
.loginPage("/auths/login-form")
.loginProcessingUrl("/process_login")
.failureUrl("/auths/login-form?error")
.and()
.exceptionHandling().accessDeniedPage("/auths/access-denied") // (1)
.and()
.authorizeHttpRequests(authorize -> authorize // (2)
.antMatchers("/orders/**").hasRole("ADMIN") // (2-1)
.antMatchers("/members/my-page").hasRole("USER") // (2-2)
.antMatchers("/**").permitAll() // (2-3)
);
return http.build();
}
@Bean
public InMemoryUserDetailsManager userDetailsService() {
UserDetails user =
User.withDefaultPasswordEncoder()
.username("kevin@gmail.com")
.password("1111")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
[코드 4-11] 요청 URI에 대한 접근 권한 부여
코드 4-8에서 .authorizeHttpRequests().anyRequest().permitAll(); 설정을 통해 로그인 인증에 성공할 경우, 모든 화면에 접근할 수 있도록 했던 부분을 코드 4-11에서는 사용자의 Role 별로 request URI에 접근 권한이 부여되도록 수정했습니다.
코드 4-11에 대한 설명은 다음과 같습니다.
- (1)에서는 exceptionHandling().accessDeniedPage("/auths/access-denied")를 통해 권한이 없는 사용자가 특정 request URI에 접근할 경우 발생하는 403(Forbidden) 에러를 처리하기 위한 페이지를 설정했습니다. 즉, 권한이 없는 사용자가 특정 request URI로 request를 전송할 경우, 아래의 [그림 4-9]와 같은 화면이 표시됩니다.
[그림 4-9] 접근 권한이 없는 request URI로 접근 시 표시되는 화면
exceptionHandling() 메서드는 메서드의 이름 그대로 Exception을 처리하는 기능을 하며, 리턴하는 ExceptionHandlingConfigurer 객체를 통해 구체적인 Exception 처리를 할 수 있습니다.
accessDeniedPage() 메서드는 403 에러 발생 시, 파라미터로 지정한 URL로 리다이렉트 되도록 해줍니다.
화면으로 보이는 html 페이지는 access-denied.html(srce/main/resources/templates)입니다.
- authorizeHttpRequests() 메서드는 (2)와 같이 람다 표현식을 통해 request URI에 대한 접근 권한을 부여할 수 있습니다.
- antMatchers() 메서드는 이름 그대로 ant라는 빌드 툴에서 사용되는 Path Pattern을 이용해서 매치되는 URL을 표현합니다.
- (2-2)의 antMatchers("/members/my-page").hasRole("USER")은 USER Role을 부여받은 사용자만 /members/my-page URL에 접근할 수 있음을 나타냅니다.
- (2-3)의 .antMatchers("/**").permitAll()은 앞에서 지정한 URL 이외의 나머지 모든 URL은 Role에 상관없이 접근이 가능함을 의미합니다.
- /orders/**에서 **는 /orders로 시작하는 모든 하위 URL을 포함합니다. 예를 들어 /orders/1, /orders/1/coffees, /orders/1/coffees/1 같은 모든 하위 URL을 포함합니다.
⭐ antMatchers()를 이용한 접근 권한 부여 시, 주의 사항
만약에 antMatcher() 메서드를 아래와 같이 순서를 바꿔서 애플리케이션을 실행하면 어떤 일이 발생할까요?
.authorizeHttpRequests(authorize -> authorize
.antMatchers("/**").permitAll() // 이 표현식이 제일 앞에 오면?
.antMatchers("/orders/**").hasRole("ADMIN")
.antMatchers("/members/my-page").hasRole("USER")
);
이처럼 antMatchers("/**").permitAll()이 제일 앞에 위치하면 Spring Security에서는 Role에 상관없이 모든 request URL에 대한 접근을 허용하기 때문에 다음에 오는 .antMatchers("/orders/**").hasRole("ADMIN")와 .antMatchers("/members/my-page").hasRole("USER")는 제 기능을 하지 못하게 되고 결과적으로 사용자의 Role과는 무관하게 모든 request URL에 접근할 수 있게 됩니다.
이와 같은 request URL 설정 오류를 방지하기 위해 항상 ⭐ 더 구체적인 URL 경로부터 접근 권한을 부여한 다음 덜 구체적인 URL 경로에 접근 권한을 부여하는 습관을 들이길 바랍니다.
Ant는 Maven과 Gradle에 밀려서 거의 사용되지 않는 빌드 툴이지만 Ant에서 사용되는 Ant Pattern은 URL 경로 등을 지정하기 위한 Pattern 표현식으로 여러 오픈 소스에서 사용되고 있습니다.
Ant Pattern에 대해서 더 알아보고 싶다면 아래의 [심화 학습]을 참고하세요.
현재까지 우리가 작성한 SecurityConfiguration 클래스에는 USER Role을 가지는 하나의 사용자만 InMemory User로 등록이 된 상태입니다.
이제 ADMIN Role을 가지는 사용자 하나를 더 추가해서 .antMatchers("/orders/**").hasRole("ADMIN")로 설정한 화면에 접근할 수 있는지 확인을 해 보도록 하겠습니다.
6️⃣ 관리자 권한을 가진 사용자 정보 추가
package com.springboot.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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfiguration {
...
...
@Bean
public InMemoryUserDetailsManager userDetailsService() {
UserDetails user =
User.withDefaultPasswordEncoder()
.username("kevin@gmail.com")
.password("1111")
.roles("USER")
.build();
// (1)
UserDetails admin =
User.withDefaultPasswordEncoder()
.username("admin@gmail.com")
.password("2222")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
}
[코드 4-12] ADMIN Role을 가진 사용자 추가
코드 4-12에서는 (1)과 같이 admin@gmail.com이라는 InMemory User 하나를 더 추가하였으며, admin@gmail.com에게는 ADMIN Role이 부여되었습니다.
애플리케이션을 다시 실행하고 샘플 애플리케이션의 메인 화면에서 [전체 주문 목록 보기] 메뉴를 클릭하면 정상적으로 접근이 가능한 것을 확인할 수 있습니다.
여러분들이 kevin@gmail.com은 로그인 후 [전체 주문 목록 보기]에 접근이 되지 않고, admin@gmail.com은 접근이 되는지 직접 한 번 확인해 보세요.
7️⃣ 로그인 한 사용자 아이디 표시 및 사용자 로그아웃
현재 화면에서는 사용자가 로그인한 후에 어떤 사용자가 로그인했는지 알 수 없습니다.
또한 로그인 한 사용자가 로그 아웃을 할 수 있는 기능도 없습니다.
그리고 마이 페이지 링크 역시 로그인 한 사용자에게만 보이는 것이 좋을 것 같습니다.
이 세 가지 기능을 샘플 애플리케이션에 추가해 봅시다.
<html xmlns:th="<http://www.thymeleaf.org>"
xmlns:sec="<http://www.thymeleaf.org/thymeleaf-extras-springsecurity5>"> <!-- (1) -->
<body>
<div align="right" th:fragment="header">
<a href="/members/register" class="text-decoration-none">회원가입</a> |
<span sec:authorize="isAuthenticated()"> <!-- (2) -->
<span sec:authorize="hasRole('USER')"> <!-- (3) -->
<a href="/members/my-page" class="text-decoration-none">마이페이지</a> |
</span>
<a href="/logout" class="text-decoration-none">로그아웃</a> <!-- (4) -->
<span th:text="${#authentication.name}">홍길동</span>님 <!-- (5) -->
</span>
<span sec:authorize="!isAuthenticated()"> <!-- (6) -->
<a href="/auths/login-form" class="text-decoration-none">로그인</a>
</span>
</div>
</body>
</html>
[코드 4-13] 로그아웃 및 권한별 메뉴 표시를 위한 코드 수정(header.html)
코드 4-13은 로그아웃 및 권한별로 메뉴 표시를 하기 위해 수정한 header.html 코드입니다.
코드 설명을 보도록 합시다.
- 타임리프 기반의 HTML 템플릿에서 사용자의 인증 정보나 권한 정보를 이용해 어떤 로직을 처리하기 위해서는 먼저 (1)과 같이 sec 태그를 사용하기 위한 XML 네임스페이스를 지정합니다.
- 네임스페이스를 지정하지 않아도 동작하지만, IDE에서 해당 태그가 빨갛게 변하는 마법을 볼 것입니다.
- (2)와 같이 <span> 태그 내부에서 sec:authorize="isAuthenticated()"를 지정하면 현재 페이지에 접근한 사용자가 인증에 성공한 사용자인지를 체크합니다.
- 즉, isAuthenticated()의 값이 true이면 <span> 태그 하위에 포함된 콘텐츠를 화면에 표시합니다.
- 마이페이지의 경우 ADMIN Role을 가진 사용자는 필요 없는 기능이므로 (3)과 같이 sec:authorize="hasRole('USER')"를 지정해서 USER Role을 가진 사용자에게만 표시되도록 합니다.
- (2)에서 isAuthenticated()의 값이 true라는 의미는 이미 로그인 한 사용자라는 의미이므로 [로그인] 메뉴 대신에 (4)와 같이 [로그아웃] 메뉴를 표시합니다. (4)의 href="/logout"에서 “/logout” URL은 SecutiryConfiguration 클래스에서 설정한 값과 같아야 합니다.
SecutiryConfiguration 클래스에서의 로그아웃 설정은 바로 이어서 살펴보겠습니다.
- (5)에서는 th:text="${#authentication.name}"를 통해 로그인 사용자의 username을 표시하고 있습니다. 이곳에는 우리가 로그인할 때 사용한 username이 표시됩니다.
- (6)에서는 sec:authorize="!isAuthenticated()"를 통해 로그인한 사용자가 아니라면 [로그인] 버튼이 표시되도록 합니다.
코드 4-13과 같이 sec 태그를 사용하기 위해서는 아래와 같이 build.gradle의 dependencies{}에 의존 라이브러리를 추가해야 합니다. dependencies { ...implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5' }
이제 마지막 단계로 Spring Security에서 로그아웃 기능을 사용하기 위한 설정 해 봅시다.
package com.springboot.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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.formLogin()
.loginPage("/auths/login-form")
.loginProcessingUrl("/process_login")
.failureUrl("/auths/login-form?error")
.and()
.logout() // (1)
.logoutUrl("/logout") // (2)
.logoutSuccessUrl("/") // (3)
.and()
.exceptionHandling().accessDeniedPage("/auths/access-denied")
.and()
.authorizeHttpRequests(authorize -> authorize
.antMatchers("/orders/**").hasRole("ADMIN")
.antMatchers("/members/my-page").hasRole("USER")
.antMatchers("/**").permitAll()
);
return http.build();
}
...
...
}
[코드 4-14] 로그아웃 설정이 추가된 SecurityConfiguration 클래스
코드 4-14에서는 로그아웃을 위한 설정이 추가되었습니다.
- 로그아웃에 대한 추가 설정을 위해서는 (1)과 같이 logout()을 먼저 호출해야 합니다. logout() 메서드는 로그아웃 설정을 위한 LogoutConfigurer를 리턴합니다.
- (2)에서는 logoutUrl("/logout")을 통해 사용자가 로그아웃을 수행하기 위한 request URL을 지정합니다. ⭐ 여기서 설정한 URL은 코드 4-13 header.html의 로그아웃 메뉴에 지정한 href=”/logout”과 동일해야 합니다.
- (3)에서는 로그아웃을 성공적으로 수행한 이후 리다이렉트 할 URL을 지정합니다. 여기서는 로그아웃 이후 샘플 애플리케이션의 메인 화면으로 리다이렉트 하도록 지정했습니다.
드디어 Hello Spring Security 샘플 애플리케이션의 첫 번째 버전이 완성되었습니다.
보안 자체가 어려운 주제이기 때문에 Spring Security에서 제공하는 기능 역시 사용하기 쉽지 않아 보이긴 하지만 여러분들이 꼭 앞에서 설명한 예제 코드를 작성한 후에 애플리케이션을 실행해서 잘 동작하는지 확인해 보길 바랍니다.
이어지는 챕터에서는 이번 챕터에서 InMemory User 정보를 이용해 로그인 인증을 진행했던 부분은 데이터베이스를 이용하도록 Hello Spring Security 샘플 애플리케이션의 기능을 업그레이드해 보겠습니다.
핵심 포인트
- Spring Security의 기본 구조와 기본적인 동작 방식을 이해하기 가장 좋은 인증 방식은 폼 로그인 인증 방식이다.
- Spring Security를 이용한 보안 설정은 HttpSecurity를 파라미터로 가지고, SecurityFilterChain을 리턴하는 Bean을 생성하면 된다.
- HttpSecurity를 통해 Spring Security에서 지원하는 보안 설정을 구성할 수 있다.
- 로컬 환경에서 Spring Security를 테스트하기 위해서는 CSRF 설정을 비활성화해야 한다.
- InMemoryUserDetailsManager를 이용해 데이터베이스 연동 없이 테스트 목적의 InMemory User를 생성할 수 있다.
심화 학습
- SSR(Server Side Rendering) 방식의 Controller 구현 방식이 궁금하다면 아래 링크를 참고하세요.
- Ant Pattern에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- XML Name Space에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.