Angular 및 Spring Boot 애플리케이션을 통한 보안에 대한 두 가지 주요 개념인 CORS
와 CSRF
에 대해 다뤄보자.
Postman을 사용하여 API 호출 시, 보안 컨셉들이 눈에 띄지 않을 수 있으므로 Angular 기반 UI 클라이언트를 따로 제공한다.
https://nodejs.org/en/download/ 에서 운영 체제에 맞게 Node.js를 다운로드하고 설치한다.
설치가 성공적인지 확인하려면 node -v
명령을 사용하여 현재 설치된 버전이 표시된다.
그리고 npm 버전도 확인하기 위해 npm -v
로 확인
npm install -g @angular/cli
명령을 실행하여 Angular CLI를 설치하자.
정상적으로 설치는 완료되었으나, npm을 업그레이드 해달라는 경고가 뜬다.
따라서 npm을 npm install -g npm@latest
명령어로 최신버전으로 변경해주었다.
그리고 Angular CLI 버전을 확인해보니까
Node.js 버전이 21.6.0 버전은 Angular를 지원하지 않는다고 한다.
그래서 18.19.0 버전으로 다운그레이드 하기 위해 Node.js의 버전을 관리하는 도구인 nvm(=Node Version Manager)을 설치하여 사용하기로 했다. 아래 링크로 들어가서
https://github.com/coreybutler/nvm-windows/
표시된 부분을 누르고
npm-setup.exe
를 다운받았다.
nvm -v
로 nvm이 잘 깔렸는지 버전확인
nvm list available
로 사용가능한 node.js 버전 확인
nvm install 18.19.0
18.19.0 버전 다운로드
설치된 버전 확인 : nvm list
원하는 node.js 버전으로 설정 : nvm use 18.19.0
번경된 node.js 18.19.0 버전은 Angular CLI가 지원한다.
https://code.visualstudio.com/ 에서 Visual Studio Code를 다운로드하고 제공된 Angular 프로젝트를 가져온다.
프로젝트 내에서 package.json이 있는 폴더로 이동하고 터미널을 열어 npm install
명령을 실행한다.
이는 node_modules
라는 새로운 폴더에 모든 dependencies를 설치한다.
Angular CLI가 설치되었는지 확인 후, ng serve
명령으로 애플리케이션 시작한다.
다운받은 dependencies을 기반으로 애플리케이션을 컴파일 하는 것이다.
http://localhost:4200
로 들어가면 페이지가 열리는 것을 확인
Angular 웹 애플리케이션의 코드 변화에 대해 이해해보자.
웹 애플리케이션 내에서의 상호작용, 백엔드와의 통신, UI 로직의 위치 등에 대한 이해를 돋구어 보고자 한다.
Angular 애플리케이션은 여러 모듈로 구성되어 있으며, 각 모듈은 특정 기능을 담당한다.
이러한 모듈은 accounts, cards, balance, contact, dashboard, header, home 등이 있다.
각 모듈 안에는 HTML, CSS, TypeScript(.component.ts), 및 테스트 파일(.spec.ts)이 포함되어 있다.
HTML(.html): 모듈의 UI를 담당하며 Angular syntax를 포함한다.
특히 백엔드에서 받아온 데이터를 표시할 때 중요한 부분이다.
CSS(.css): HTML에 대응하는 스타일을 정의한다..
TypeScript(.component.ts): 해당 모듈의 주요 비지니스 로직을 담고 있다.
백엔드와의 통신, 데이터 처리 등이 이루어진다.
이 요소 클래스 안에서만 대부분의 비지니스 로직들을 쓰게 된다.
현재 HomeCompent를 보면 아무 로직이 없다.
이 요소의 목적은 그저 홈페이지를 보여주는 것이기 때문이다.
요소의 스타일은 ./home.component.css
로부터 받는다.
구조는 ./home.component.html
파일로부터 받는 것이며
요소가 가진 선택자 app-home
이 있다.
Model(.model.ts) : 간단한 POJO(Plain Old Java Object) 클래스로 구성되어 있다.
모델 클래스를 통해서만 백엔드와의 통신에 사용되며 백엔드로부터 데이터를 가져올 것이다.
Services(.service.ts) : 백엔드와의 통신을 담당하는 서비스 패키지들이 모여 있다.
Angular에서는 모든 것은 TypeScript(.ts)로 쓰여야 하며 일반 JavaScript(.js)를 사용해선 안 된다.
각 서비스는 TypeScript로 작성되어야 하며, 일반 JavaScript를 사용해선 안 된다.
컴파일 과정에서 TypeScript가 JavaScript로 변환된다.
브라우저들은 TypeScript를 이해할 수 없고 JavaScript만 이해할 수 있다.
백엔드와의 소통을 담당한다.
여기서 LoginService 클래스의 validateLoginDetails
를 살펴보면 get 함수를 사용해 URL을 요청한다.
애플리케이션의 각 경로는 app-routing.module.ts
에서 정의되어 있다.
경로에 따라 해당하는 컴포넌트가 불러와지며, 특정 조건에 따라 접근이 제어될 수 있다.
인증과 보안은 AuthActivateRouteGuard
를 통해 관리된다.
예를 들어 사용자가 로그인하기 위해 아이디 패스워드를 입력한 뒤 Sign In을 눌렀다고 가정하자.
여기서는 validateUser
함수를 불러오라고 하고 있는데 이 함수 안에는 로그인과 관련된 요소 안에 있다.
login.component.ts
에 들어가면 validateUser
라는 함수가 있다.
이 함수를 통해서 login.service.ts
안에 있는 validateLoginDetails
함수를 불러오는 것이다.
validateUser
안에 있는 함수를 살펴보면 그저 로그인 서비스 안에 사용 가능한 함수를 불러오는 것 뿐이다. 이 메소드는 REST API를 불러오기 때문에 나는 subscribe
(등록)을 하려고 한다.
subscribe
는 Angular의 개념으로써 백엔드 서버와 소통할 때 사용한다.
이는 비동기식 통신을 지원한다.
백엔드로부터 받은 응답 데이터(responseData)를 수락하고, 그 바디(body)를 가져온다. this.model = <any> responseData.body;
그 다음 비지니스 로직을 실행한다. 그리고 마지막으로 로그인이 성공적일 때 유저를 대시보드로 재이동 시킨다. this.router.navigate(['dashboard']);
validateLoginDetails
함수는 백엔드로 가는 get API 요청을 만드는 것이다.
대시보드로 재이동 시킬 시에 app-routing.module.ts
에서 path
가 dashboard
인 것을 찾는다. 여기 component
는 DashboardComponent
라는 요소를 불러올 것이다. canAcivate
같은 것은 보호를 위한 역할이며 엔드 유저들이 로그인 없이 접속하지 못하도록 한다.
home
,login
,contack
,notices
,logout
은 전부 공용의 목적으로써 누구든 접속 가능하도록 canAcivate
을 설정하지 않았다.
dashboard
,logout
,myAccount
,myBalance
,myLoans
,myCard
는 오직 엔드 유저가 로그인 했을 경우만 접속 가능하다.
canAcivate
을 쓰면 AuthActiveRouteGuard
안에 특정 로직들을 정의한다.
auth.routeguard.ts
파일의 AuthActiveRouteGuard
클래스를 보면 userDetails
를 sessionStorage
에서 받으려 하는데sessionStorage
에 userDetails
가 있거나 유저 객체가 null이 아닐 경우 true를 반환하고 반대인 경우 false를 반환한다.
login.component.ts
를 확인해보면 응답(responseData)을 받자마자 객체가 생성되고, 그게 sessionStorage 안에 있는 userDetails이다.
이는 오직 인증이 완료된 후에만 이 정보들이 전용 sessionStorage에 저장된다는 뜻이다.
그것과 함께 authStatus
에는 로그인 요소 안에 있는 AUTH
값도 함께 저장 중이다.
결국 이것의 목적은 오직 이 AUTH
값을 사용해서만 헤더에 무엇이 표현될지 결정하는 것이다.
로그인을 했다면 authStatus가 AUTH가 되고, 안했다면 AUTH가 아닐 것이다.
ngif(if문)을 사용해 authStatu 값이 AUTH인 경우 대시보드와 로그아웃만 표시하라고 명령한다. 반대의 경우 공용 링크들만 표시하도록 한다.
애플리케이션에서 백엔드로의 모든 요청은 인터셉터를 통과하게 되며, 사용자 인증 정보를 담아 보낼 수 있다.
app.request.interceptor.ts
파일에 위치해 있다.
userDetails
를 sessionStorage
로부터 받으려 하고 user 객체에 동일한 것을 할당한다.
user 객체와 비밀번호, 이메일이 있을 경우 (= 유저가 애플리케이션에 아주 처음 로그인한다는 뜻, 그 때는 오로지 비밀번호와 이메일만 있다.) 그저 Authorization
헤더와 Basic
을 그리고 이메일과 비밀번호를 차례로 보내려는 것이다.
그리고 다른 모든 경우에는 즉, 비밀번호와 이메일이 비어있을 때는 유저를 재인증하지 않을 것이다. 왜냐하면 두 번째 이후로부턴 계속 인증이 이미 이루어졌고 이 authorization 헤더를 보낼 필요가 없다.
그렇다면 Spring Boot는 유저가 인증되었는지 아닌지 어떻게 알 수 있는가?
그 부분은 dashboard.service.ts
에 가면 여러 REST 서비스들이 있는데 백엔드에서 REST API를 부를 때마다 withCredentials
의 값을 true라고 전달한다.
withCredentials
가 true 일 때 Angular에게 갖고 있는 쿠키들이나 Session ID 또는 토큰들을 백엔드로부터 찾아 달라는 요청이다.
그래야 Spring Security에서 인증이 이미 이루어졌는지 아닌지 이해할 수 있다.
이번 강의에서는 데이터베이스의 구조를 변경하고, 백엔드 애플리케이션을 수정하여 UI에 실제 데이터를 전송할 수 있는 기능을 구현한다.
IntelliJ IDEA를 열고, 이전에 생성한 springsecuritysection5 프로젝트를 복사하여 springsecurity6로 이름을 변경한다.
pom.xml 파일에서 artifactId를 springsecurity6로 수정한다.
데이터베이스에 새로운 테이블 생성을 위해 스크립트를 작성한다.
scripts.sql 파일에 다음 테이블들의 생성 및 데이터 추가 스크립트를 추가한다.
create database eazybank;
use eazybank;
drop table `users`;
drop table `authorities`;
drop table `customer`;
CREATE TABLE `customer` (
`customer_id` int NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`email` varchar(100) NOT NULL,
`mobile_number` varchar(20) NOT NULL,
`pwd` varchar(500) NOT NULL,
`role` varchar(100) NOT NULL,
`create_dt` date DEFAULT NULL,
PRIMARY KEY (`customer_id`)
);
INSERT INTO `customer` (`name`,`email`,`mobile_number`, `pwd`, `role`,`create_dt`)
VALUES ('Happy','happy@example.com','9876548337', '$2y$12$oRRbkNfwuR8ug4MlzH5FOeui.//1mkd.RsOAJMbykTSupVy.x/vb2', 'admin',CURDATE());
CREATE TABLE `accounts` (
`customer_id` int NOT NULL,
`account_number` int NOT NULL,
`account_type` varchar(100) NOT NULL,
`branch_address` varchar(200) NOT NULL,
`create_dt` date DEFAULT NULL,
PRIMARY KEY (`account_number`),
KEY `customer_id` (`customer_id`),
CONSTRAINT `customer_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customer` (`customer_id`) ON DELETE CASCADE
);
INSERT INTO `accounts` (`customer_id`, `account_number`, `account_type`, `branch_address`, `create_dt`)
VALUES (1, 1865764534, 'Savings', '123 Main Street, New York', CURDATE());
CREATE TABLE `account_transactions` (
`transaction_id` varchar(200) NOT NULL,
`account_number` int NOT NULL,
`customer_id` int NOT NULL,
`transaction_dt` date NOT NULL,
`transaction_summary` varchar(200) NOT NULL,
`transaction_type` varchar(100) NOT NULL,
`transaction_amt` int NOT NULL,
`closing_balance` int NOT NULL,
`create_dt` date DEFAULT NULL,
PRIMARY KEY (`transaction_id`),
KEY `customer_id` (`customer_id`),
KEY `account_number` (`account_number`),
CONSTRAINT `accounts_ibfk_2` FOREIGN KEY (`account_number`) REFERENCES `accounts` (`account_number`) ON DELETE CASCADE,
CONSTRAINT `acct_user_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customer` (`customer_id`) ON DELETE CASCADE
);
INSERT INTO `account_transactions` (`transaction_id`, `account_number`, `customer_id`, `transaction_dt`, `transaction_summary`, `transaction_type`,`transaction_amt`,
`closing_balance`, `create_dt`) VALUES (UUID(), 1865764534, 1, DATE_SUB(CURDATE(), INTERVAL 7 DAY), 'Coffee Shop', 'Withdrawal', 30,34500,DATE_SUB(CURDATE(), INTERVAL 7 DAY));
INSERT INTO `account_transactions` (`transaction_id`, `account_number`, `customer_id`, `transaction_dt`, `transaction_summary`, `transaction_type`,`transaction_amt`,
`closing_balance`, `create_dt`) VALUES (UUID(), 1865764534, 1, DATE_SUB(CURDATE(), INTERVAL 6 DAY), 'Uber', 'Withdrawal', 100,34400,DATE_SUB(CURDATE(), INTERVAL 6 DAY));
INSERT INTO `account_transactions` (`transaction_id`, `account_number`, `customer_id`, `transaction_dt`, `transaction_summary`, `transaction_type`,`transaction_amt`,
`closing_balance`, `create_dt`) VALUES (UUID(), 1865764534, 1, DATE_SUB(CURDATE(), INTERVAL 5 DAY), 'Self Deposit', 'Deposit', 500,34900,DATE_SUB(CURDATE(), INTERVAL 5 DAY));
INSERT INTO `account_transactions` (`transaction_id`, `account_number`, `customer_id`, `transaction_dt`, `transaction_summary`, `transaction_type`,`transaction_amt`,
`closing_balance`, `create_dt`) VALUES (UUID(), 1865764534, 1, DATE_SUB(CURDATE(), INTERVAL 4 DAY), 'Ebay', 'Withdrawal', 600,34300,DATE_SUB(CURDATE(), INTERVAL 4 DAY));
INSERT INTO `account_transactions` (`transaction_id`, `account_number`, `customer_id`, `transaction_dt`, `transaction_summary`, `transaction_type`,`transaction_amt`,
`closing_balance`, `create_dt`) VALUES (UUID(), 1865764534, 1, DATE_SUB(CURDATE(), INTERVAL 2 DAY), 'OnlineTransfer', 'Deposit', 700,35000,DATE_SUB(CURDATE(), INTERVAL 2 DAY));
INSERT INTO `account_transactions` (`transaction_id`, `account_number`, `customer_id`, `transaction_dt`, `transaction_summary`, `transaction_type`,`transaction_amt`,
`closing_balance`, `create_dt`) VALUES (UUID(), 1865764534, 1, DATE_SUB(CURDATE(), INTERVAL 1 DAY), 'Amazon.com', 'Withdrawal', 100,34900,DATE_SUB(CURDATE(), INTERVAL 1 DAY));
CREATE TABLE `loans` (
`loan_number` int NOT NULL AUTO_INCREMENT,
`customer_id` int NOT NULL,
`start_dt` date NOT NULL,
`loan_type` varchar(100) NOT NULL,
`total_loan` int NOT NULL,
`amount_paid` int NOT NULL,
`outstanding_amount` int NOT NULL,
`create_dt` date DEFAULT NULL,
PRIMARY KEY (`loan_number`),
KEY `customer_id` (`customer_id`),
CONSTRAINT `loan_customer_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customer` (`customer_id`) ON DELETE CASCADE
);
INSERT INTO `loans` ( `customer_id`, `start_dt`, `loan_type`, `total_loan`, `amount_paid`, `outstanding_amount`, `create_dt`)
VALUES ( 1, '2020-10-13', 'Home', 200000, 50000, 150000, '2020-10-13');
INSERT INTO `loans` ( `customer_id`, `start_dt`, `loan_type`, `total_loan`, `amount_paid`, `outstanding_amount`, `create_dt`)
VALUES ( 1, '2020-06-06', 'Vehicle', 40000, 10000, 30000, '2020-06-06');
INSERT INTO `loans` ( `customer_id`, `start_dt`, `loan_type`, `total_loan`, `amount_paid`, `outstanding_amount`, `create_dt`)
VALUES ( 1, '2018-02-14', 'Home', 50000, 10000, 40000, '2018-02-14');
INSERT INTO `loans` ( `customer_id`, `start_dt`, `loan_type`, `total_loan`, `amount_paid`, `outstanding_amount`, `create_dt`)
VALUES ( 1, '2018-02-14', 'Personal', 10000, 3500, 6500, '2018-02-14');
CREATE TABLE `cards` (
`card_id` int NOT NULL AUTO_INCREMENT,
`card_number` varchar(100) NOT NULL,
`customer_id` int NOT NULL,
`card_type` varchar(100) NOT NULL,
`total_limit` int NOT NULL,
`amount_used` int NOT NULL,
`available_amount` int NOT NULL,
`create_dt` date DEFAULT NULL,
PRIMARY KEY (`card_id`),
KEY `customer_id` (`customer_id`),
CONSTRAINT `card_customer_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customer` (`customer_id`) ON DELETE CASCADE
);
INSERT INTO `cards` (`card_number`, `customer_id`, `card_type`, `total_limit`, `amount_used`, `available_amount`, `create_dt`)
VALUES ('4565XXXX4656', 1, 'Credit', 10000, 500, 9500, CURDATE());
INSERT INTO `cards` (`card_number`, `customer_id`, `card_type`, `total_limit`, `amount_used`, `available_amount`, `create_dt`)
VALUES ('3455XXXX8673', 1, 'Credit', 7500, 600, 6900, CURDATE());
INSERT INTO `cards` (`card_number`, `customer_id`, `card_type`, `total_limit`, `amount_used`, `available_amount`, `create_dt`)
VALUES ('2359XXXX9346', 1, 'Credit', 20000, 4000, 16000, CURDATE());
CREATE TABLE `notice_details` (
`notice_id` int NOT NULL AUTO_INCREMENT,
`notice_summary` varchar(200) NOT NULL,
`notice_details` varchar(500) NOT NULL,
`notic_beg_dt` date NOT NULL,
`notic_end_dt` date DEFAULT NULL,
`create_dt` date DEFAULT NULL,
`update_dt` date DEFAULT NULL,
PRIMARY KEY (`notice_id`)
);
INSERT INTO `notice_details` ( `notice_summary`, `notice_details`, `notic_beg_dt`, `notic_end_dt`, `create_dt`, `update_dt`)
VALUES ('Home Loan Interest rates reduced', 'Home loan interest rates are reduced as per the goverment guidelines. The updated rates will be effective immediately',
CURDATE() - INTERVAL 30 DAY, CURDATE() + INTERVAL 30 DAY, CURDATE(), null);
INSERT INTO `notice_details` ( `notice_summary`, `notice_details`, `notic_beg_dt`, `notic_end_dt`, `create_dt`, `update_dt`)
VALUES ('Net Banking Offers', 'Customers who will opt for Internet banking while opening a saving account will get a $50 amazon voucher',
CURDATE() - INTERVAL 30 DAY, CURDATE() + INTERVAL 30 DAY, CURDATE(), null);
INSERT INTO `notice_details` ( `notice_summary`, `notice_details`, `notic_beg_dt`, `notic_end_dt`, `create_dt`, `update_dt`)
VALUES ('Mobile App Downtime', 'The mobile application of the EazyBank will be down from 2AM-5AM on 12/05/2020 due to maintenance activities',
CURDATE() - INTERVAL 30 DAY, CURDATE() + INTERVAL 30 DAY, CURDATE(), null);
INSERT INTO `notice_details` ( `notice_summary`, `notice_details`, `notic_beg_dt`, `notic_end_dt`, `create_dt`, `update_dt`)
VALUES ('E Auction notice', 'There will be a e-auction on 12/08/2020 on the Bank website for all the stubborn arrears.Interested parties can participate in the e-auction',
CURDATE() - INTERVAL 30 DAY, CURDATE() + INTERVAL 30 DAY, CURDATE(), null);
INSERT INTO `notice_details` ( `notice_summary`, `notice_details`, `notic_beg_dt`, `notic_end_dt`, `create_dt`, `update_dt`)
VALUES ('Launch of Millennia Cards', 'Millennia Credit Cards are launched for the premium customers of EazyBank. With these cards, you will get 5% cashback for each purchase',
CURDATE() - INTERVAL 30 DAY, CURDATE() + INTERVAL 30 DAY, CURDATE(), null);
INSERT INTO `notice_details` ( `notice_summary`, `notice_details`, `notic_beg_dt`, `notic_end_dt`, `create_dt`, `update_dt`)
VALUES ('COVID-19 Insurance', 'EazyBank launched an insurance policy which will cover COVID-19 expenses. Please reach out to the branch for more details',
CURDATE() - INTERVAL 30 DAY, CURDATE() + INTERVAL 30 DAY, CURDATE(), null);
CREATE TABLE `contact_messages` (
`contact_id` varchar(50) NOT NULL,
`contact_name` varchar(50) NOT NULL,
`contact_email` varchar(100) NOT NULL,
`subject` varchar(500) NOT NULL,
`message` varchar(2000) NOT NULL,
`create_dt` date DEFAULT NULL,
PRIMARY KEY (`contact_id`)
);
앞전에 생성한 새로운 데이터 틀을 적용하기 위해 엔터티 클래스들을 생성하고, 해당 클래스들을 데이터베이스 구조와 매칭시켰다.
각 테이블에 새로운 엔터티 클래스를 수정 및 생성하였다.
package com.eazybytes.springsecsection2.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class Accounts {
@Column(name = "customer_id")
private int customerId;
@Id
@Column(name="account_number")
private long accountNumber;
@Column(name="account_type")
private String accountType;
@Column(name = "branch_address")
private String branchAddress;
@Column(name = "create_dt")
private String createDt;
..getter, setter 생략
}
package com.eazybytes.springsecsection2.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.sql.Date;
@Entity
@Table(name="account_transactions")
public class AccountTransactions {
@Id
@Column(name = "transaction_id")
private String transactionId;
@Column(name="account_number")
private long accountNumber;
@Column(name = "customer_id")
private int customerId;
@Column(name="transaction_dt")
private Date transactionDt;
@Column(name = "transaction_summary")
private String transactionSummary;
@Column(name="transaction_type")
private String transactionType;
@Column(name = "transaction_amt")
private int transactionAmt;
@Column(name = "closing_balance")
private int closingBalance;
@Column(name = "create_dt")
private String createDt;
..getter, setter 생략
}
package com.eazybytes.springsecsection2.model;
import java.sql.Date;
import jakarta.persistence.*;
import org.hibernate.annotations.GenericGenerator;
@Entity
@Table(name = "cards")
public class Cards {
@Id
@GeneratedValue(strategy= GenerationType.AUTO,generator="native")
@GenericGenerator(name = "native",strategy = "native")
@Column(name = "card_id")
private int cardId;
@Column(name = "customer_id")
private int customerId;
@Column(name = "card_number")
private String cardNumber;
@Column(name = "card_type")
private String cardType;
@Column(name = "total_limit")
private int totalLimit;
@Column(name = "amount_used")
private int amountUsed;
@Column(name = "available_amount")
private int availableAmount;
@Column(name = "create_dt")
private Date createDt;
..getter, setter 생략
}
package com.eazybytes.springsecsection2.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.sql.Date;
@Entity
@Table(name = "contact_messages")
public class Contact {
@Id
@Column(name = "contact_id")
private String contactId;
@Column(name = "contact_name")
private String contactName;
@Column(name = "contact_email")
private String contactEmail;
private String subject;
private String message;
@Column(name = "create_dt")
private Date createDt;
..getter, setter 생략
}
package com.eazybytes.springsecsection2.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
import org.hibernate.annotations.GenericGenerator;
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "customer_id")
private int id;
private String name;
private String email;
@Column(name = "mobile_number")
private String mobileNumber;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String pwd;
private String role;
@Column(name = "create_dt")
private String createDt;
..getter, setter 생략
}
package com.eazybytes.springsecsection2.model;
import java.sql.Date;
import jakarta.persistence.*;
import org.hibernate.annotations.GenericGenerator;
@Entity
@Table(name="loans")
public class Loans {
@Id
@GeneratedValue(strategy= GenerationType.AUTO,generator="native")
@GenericGenerator(name = "native",strategy = "native")
@Column(name = "loan_number")
private int loanNumber;
@Column(name = "customer_id")
private int customerId;
@Column(name="start_dt")
private Date startDt;
@Column(name = "loan_type")
private String loanType;
@Column(name = "total_loan")
private int totalLoan;
@Column(name = "amount_paid")
private int amountPaid;
@Column(name = "outstanding_amount")
private int outstandingAmount;
@Column(name = "create_dt")
private String createDt;
..getter, setter 생략
}
package com.eazybytes.springsecsection2.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.sql.Date;
import jakarta.persistence.*;
import org.hibernate.annotations.GenericGenerator;
@Entity
@Table(name = "notice_details")
public class Notice {
@Id
@GeneratedValue(strategy= GenerationType.AUTO,generator="native")
@GenericGenerator(name = "native",strategy = "native")
@Column(name = "notice_id")
private int noticeId;
@Column(name = "notice_summary")
private String noticeSummary;
@Column(name = "notice_details")
private String noticeDetails;
@Column(name = "notic_beg_dt")
private Date noticBegDt;
@Column(name = "notic_end_dt")
private Date noticEndDt;
@Column(name = "create_dt")
private Date createDt;
@Column(name = "update_dt")
private Date updateDt;
..getter, setter 생략
}
각 엔터티 클래스에 대응하는 레포지토리 인터페이스들을 생성하였다.
각각의 레포지토리는 해당 엔터티와 관련된 데이터베이스 조작을 수행한다.
package com.eazybytes.springsecsection2.repository;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import com.eazybytes.springsecsection2.model.Accounts;
@Repository
public interface AccountsRepository extends CrudRepository<Accounts, Long> {
Accounts findByCustomerId(int customerId);
}
package com.eazybytes.springsecsection2.repository;
import java.util.List;
import com.eazybytes.springsecsection2.model.AccountTransactions;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface AccountTransactionsRepository extends CrudRepository<AccountTransactions, Long> {
List<AccountTransactions> findByCustomerIdOrderByTransactionDtDesc(int customerId);
}
package com.eazybytes.springsecsection2.repository;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import com.eazybytes.springsecsection2.model.Cards;
@Repository
public interface CardsRepository extends CrudRepository<Cards, Long> {
List<Cards> findByCustomerId(int customerId);
}
package com.eazybytes.springsecsection2.repository;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import com.eazybytes.springsecsection2.model.Contact;
@Repository
public interface ContactRepository extends CrudRepository<Contact, Long> {
}
package com.eazybytes.springsecsection2.repository;
import com.eazybytes.springsecsection2.model.Customer;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface CustomerRepository extends CrudRepository<Customer, Long> {
List<Customer> findByEmail(String email);
}
package com.eazybytes.springsecsection2.repository;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import com.eazybytes.springsecsection2.model.Loans;
@Repository
public interface LoanRepository extends CrudRepository<Loans, Long> {
List<Loans> findByCustomerIdOrderByStartDtDesc(int customerId);
}
package com.eazybytes.springsecsection2.repository;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import com.eazybytes.springsecsection2.model.Notice;
@Repository
public interface NoticeRepository extends CrudRepository<Notice, Long> {
@Query(value = "from Notice n where CURDATE() BETWEEN noticBegDt AND noticEndDt")
List<Notice> findAllActiveNotices();
}
각각의 엔터티에 대한 정보를 제공하기 위해 컨트롤러 클래스를 수정하였다.
프론트엔드로부터 받은 정보를 기반으로 데이터베이스에서 정보를 조회하고 반환하는 로직이 구현되었다.
package com.eazybytes.springsecsection2.controller;
import com.eazybytes.springsecsection2.model.Accounts;
import com.eazybytes.springsecsection2.repository.AccountsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class AccountController {
private final AccountsRepository accountsRepository;
@GetMapping("/myAccount")
public Accounts getAccountDetails(@RequestParam int id) {
Accounts accounts = accountsRepository.findByCustomerId(id);
if (accounts != null ) {
return accounts;
}else {
return null;
}
}
}
package com.eazybytes.springsecsection2.controller;
import com.eazybytes.springsecsection2.model.AccountTransactions;
import com.eazybytes.springsecsection2.repository.AccountTransactionsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
public class BalanceController {
private final AccountTransactionsRepository accountTransactionsRepository;
@GetMapping("/myBalance")
public List<AccountTransactions> getBalanceDetails(@RequestParam int id) {
List<AccountTransactions> accountTransactions = accountTransactionsRepository.
findByCustomerIdOrderByTransactionDtDesc(id);
if (accountTransactions != null ) {
return accountTransactions;
}else {
return null;
}
}
}
package com.eazybytes.springsecsection2.controller;
import com.eazybytes.springsecsection2.model.Cards;
import com.eazybytes.springsecsection2.repository.CardsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
public class CardsController {
private final CardsRepository cardsRepository;
@GetMapping("/myCards")
public List<Cards> getCardDetails(@RequestParam int id) {
List<Cards> cards = cardsRepository.findByCustomerId(id);
if (cards != null ) {
return cards;
}else {
return null;
}
}
}
AccountController, BalanceController, CardsController의 메서드들은 프론트엔드 애플리케이션으로부터 id를 받아 정보를 가져오고 그를 반환하고 있다.
package com.eazybytes.springsecsection2.controller;
import java.sql.Date;
import java.util.Random;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import com.eazybytes.springsecsection2.model.Contact;
import com.eazybytes.springsecsection2.repository.ContactRepository;
@RestController
@RequiredArgsConstructor
public class ContactController {
private final ContactRepository contactRepository;
@PostMapping("/contact")
public Contact saveContactInquiryDetails(@RequestBody Contact contact) {
contact.setContactId(getServiceReqNumber());
contact.setCreateDt(new Date(System.currentTimeMillis()));
return contactRepository.save(contact);
}
public String getServiceReqNumber() {
Random random = new Random();
int ranNum = random.nextInt(999999999 - 9999) + 9999;
return "SR"+ranNum;
}
}
ContactController
메서드에서는 프론트에서 가져온 정보들을 저장할 것이다.
누구든 문의하기 페이지에 정보를 제출하면 그 정보들(=Contact)이 POST 매핑을 통해 매개변수로 가져온다.
연락처(=Contact) 정보를 데이터베이스에 저장하려고 하면 setContactId()
메서드를 통해 getServiceReqNumber()
사용자의 정의 메서드에서 받아온 랜덤한 숫자를 Contact 객체에 주입한다.
참고로 Contact Entity에서 pk 값인 contactId는 자동으로 생성되는 sequenceNumber
를 사용하지 않고 있다.
package com.eazybytes.springsecsection2.controller;
import com.eazybytes.springsecsection2.model.Loans;
import com.eazybytes.springsecsection2.repository.LoanRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
public class LoansController {
private final LoanRepository loanRepository;
@GetMapping("/myLoans")
public List<Loans> getLoanDetails(@RequestParam int id) {
List<Loans> loans = loanRepository.findByCustomerIdOrderByStartDtDesc(id);
if (loans != null ) {
return loans;
}else {
return null;
}
}
}
LoansController
의 메서드 또한 프론트엔드 애플리케이션으로부터 id를 받아 정보를 가져오고 그를 반환하고 있다.
package com.eazybytes.springsecsection2.controller;
import com.eazybytes.springsecsection2.model.Customer;
import com.eazybytes.springsecsection2.repository.CustomerRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.sql.Date;
import java.util.List;
@RestController
@RequiredArgsConstructor
public class LoginController {
private final CustomerRepository customerRepository;
private final PasswordEncoder passwordEncoder;
@PostMapping("/register")
public ResponseEntity<String> registerUser(@RequestBody Customer customer) {
Customer savedCustomer = null;
ResponseEntity response = null;
try {
String hashPwd = passwordEncoder.encode(customer.getPwd());
customer.setPwd(hashPwd);
customer.setCreateDt(String.valueOf(new Date(System.currentTimeMillis())));
savedCustomer = customerRepository.save(customer);
if (savedCustomer.getId() > 0) {
response = ResponseEntity
.status(HttpStatus.CREATED)
.body("Given user details are successfully registered");
}
} catch (Exception ex) {
response = ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("An exception occured due to " + ex.getMessage());
}
return response;
}
@RequestMapping("/user")
public Customer getUserDetailsAfterLogin(Authentication authentication) {
List<Customer> customers = customerRepository.findByEmail(authentication.getName());
if (customers.size() > 0) {
return customers.get(0);
} else {
return null;
}
}
}
LoginController
의 사용자 등록 registerUser()
메소드는 이전 코드와 동일
getUserDetailsAfterLogin()
메서드는 엔드 유저의 로그인 동작이 시작될 때마다 호출되어 고객 정보를 데이터베이스에서 가져와 프론트엔드로 전달한다.
package com.eazybytes.springsecsection2.controller;
import java.util.List;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import com.eazybytes.springsecsection2.model.Notice;
import com.eazybytes.springsecsection2.repository.NoticeRepository;
@RestController
@RequiredArgsConstructor
public class NoticesController {
private final NoticeRepository noticeRepository;
@GetMapping("/notices")
public ResponseEntity<List<Notice>> getNotices() {
List<Notice> notices = noticeRepository.findAllActiveNotices();
if (notices != null) {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS))
.body(notices);
} else {
return null;
}
}
}
NoticesController
에서는 findAllActiveNotices()
메소드를 통해 모든 공지사항 정보를 가져오는 기능을 구현하였다.
이 정보는 캐시와 관련된 헤더 정보와 함께 응답
-> 어떤 공지사항 정보를 보내든 다음 60초 동안 이용하라는 의미
-> 대신 캐시 안에 이미 들어있는 공지사항(notices) 정보를 사용하기 위해 60초 동안은 유효하다.
프로젝트 보안 설정
ProjectSecurityConfig
클래스 /user
서비스는 인증되어야만 접근 가능하도록 설정되었다. permitAll()은 사용되지 않는다.
package com.eazybytes.springsecsection2.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import javax.sql.DataSource;
@Configuration
public class ProjectSecurityConfig {
//람다(Lambda) DSL 스타일 사용을 권장
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
/**
* 사용자 정의 보안 설정
*/
http.csrf((csrf) -> csrf.disable())
.authorizeHttpRequests((requests)->requests
.requestMatchers("/myAccount","/myBalance","/myLoans","/myCards","/user").authenticated()
.requestMatchers("/notices","/contact","/register").permitAll())
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
백엔드와 데이터베이스 스키마에 대한 변경사항을 살펴보며, 등록 API 서비스를 테스트하여 고객 정보를 확인해보자.
Postman을 사용하여 유효한 고객 정보를 등록 API 서비스에 전송한다.
URL 및 포스트 메타 설정 후, Request Body에서는 JSON 형식으로 필요한 필드들(name, email, mobileNumber, pwd, role)을 입력한다.
URL 설정 및 포스트 메타 수용
Request Body에서는 raw 옵션 선택, JSON 포맷 선택
필요한 필드들을 Customer 엔터티 클래스의 문자열 이름에 따라 작성
id 등 불필요한 필드는 데이터베이스에서 자동 생성되므로 생략
createDt는 보내지 않고, LoginController의 등록 API 서비스 메소드에서 현재 시스템 날짜 기반으로 생성하여 문자열로 변환하여 creatDt로 설정
중단점 설정 후 Postman에서 응답 확인
등록 유저 메소드 내에서 디버그 실행
비밀번호 해시, customerRepository 설정, create date 생성, sale 메소드 실행 등의 과정 확인
중단점 해제 후 Postman 응답 확인, 데이터베이스에서 새로운 기록 확인
백엔드와 데이터베이스 스키마의 큰 변화에도 불구하고 등록 API를 테스트하여 성공적으로 완료했다
Angular 애플리케이션에서는 회원가입 버튼이 작동하지 않는 이유는 고의적으로 구현하지 않았기 때문이다.
Angular를 공부하는게 아니라 Spring Security를 공부하는 것이기 때문에 Postman을 활용하여 유연하게 테스트한다.
Register API 서비스 테스트 성공 후, Notices API 테스트를 진행해보자.
문제 발생 시 Spring Security를 활용하여 해결할 것이다.
CORS 정책 이슈가 발생하는 모습을 직접 테스트하여 확인해보자.
응답 확인: 6개의 활성화된 notice 표시
Angular UI 애플리케이션을 통해 Notices 페이지 접근
Backend로부터 응답을 받아와서 Notices 정보를 표시
헤더(header) HTML에 위치한 페이지들: HOME, LOGIN, CONTACT US, NOTICES
헤더 HTML에서 인증되지 않은 경우 Home, Login, Contact Us,
Notices 표시
헤더에 NOTICES 클릭 시 Angular 라우터를 통해 백엔드로 HTTP GET 요청하며, 데이터를 받아와 UI에 표시.
누군가 NOTICES을 클릭하면 Augular에게 '유저를 /notices로 이동시켜줘'라고 말하는 것과 같다.
라우팅 정보들은 app-roution.module.ts
에 설정했다.
notices
경로로는 NoticesComponent
를 찾고 불러오라고 명령하는 것이다.
NoticesComponent
에는 component가 초기화하는 중에 누군가 ngOnInit()
요소에 접근하려 하면 dashboardService
안에 있는 getNoticeDetails()
함수를 불러온다.
백엔드에 있는 Rest api를 불러오는 것이기 때문에 그것을 subscribe
하려는 것이다.
응답(responseData)을 받으면 마찬가지로 NoticesComponent
클래스 안에 있는 notices
배열에 들어간다.
notices
객체는 notices의 HTML 페이지를 확인해보면 (=notices.coponent.html) ngFor
를 사용 중이다.
Angular에서 ngFor
는 요소 리스트를 notices
배열에 넣는다.
let notice of notices
코드는 각 요소를 반복하게 하는 것이다.
모든 요소는 div 안에 반환하면 실행된다.
각 notices
객체에서 noticeSummary
와 noticeDetails
를 가져오고 동일하게 UI 페이지에 표시된다.
notice.component.ts
에 돌아가서 dashboardService
의 getNoticeDetails()
를 클릭하고
rootUrl
에게 HTTP GET 요청을 할 수 있도록 한다.
경로는 /notices
이다.
그리고 observe를 response 설정하면 (observe:'response'
) 헤더나 바디 따로가 아닌 전부를 원한다고 요구하는 것이다.
notice.component.ts
에 컴포넌트도 확인해보면 바디는 백엔드 서버에서 받은 응답 객체에서 추출한다.
withCredentials:true
라고 설정하지 않았기 때문에 모든 사용자가 쓸 수 있는 공용 API이다.
이전 강의에서 간단한 웹 퍼블리케이션을 통해 CORS 문제를 다뤘다.
이번 강의에서는 CORS가 무엇인지 알아보고, Spring Security를 활용하여 이 문제를 해결할 것이다.
CORS는 Cross Origin Resource Sharing의 약자로, 두 출처 간의 자원 공유를 의미한다.
출처는 URL이며, HTTP 프로토콜, 도메인, 포트 넘버의 조합이 출처를 결정한다.
두 출처 간에는 보안상의 이유로 기본적으로 소통이 차단된다.
출처는 URL의 조합으로 이루어져 있다. 예를 들어, 도메인, 호스트 이름, 포트 넘버가 다르면 다른 출처이다.
보안 상의 이유로 브라우저는 두 출처 간의 소통을 차단한다.
최신 브라우저들은 CORS가 보안적인 차원에서 작동한다.
보안 위험에서 보호하기 위해 서로 다른 출처 간의 소통을 차단한다.
유효한 사유로 출처 간 소통이 필요한 경우, 브라우저에게 허용을 받아야 한다.
Angular 애플리케이션이 Spring Boot 애플리케이션과 소통하는 경우와 유사하게, 기업들은 서로 다른 출처로 지정된 웹 애플리케이션과 소통할 수 있다.
Angular와 Spring Boot 애플리케이션 간의 통신이 CORS로 인해 차단되었다.
최신 브라우저의 보안 정책으로 발생한 것이라고 앞전에 말했다.
도메인과 HTTP 프로토콜은 동일하지만 포트가 다른 경우에도 발생한다.
실습환경에서 Spring Boot는 8000번이나 Angular는 4200번의 포트번호를 가지고 있어 CORS 문제가 발생하는 것이다.
해당 controller 클래스 또는 REST API에 주석을 추가한다.
@CrossOrigin 주석을 통해 통신을 허용할 출처를 설정한다.
어느 출처에서 통신을 받으려는 것인지 말해주는 것이다.
이렇게되면 오직 'http://localhost:4200' Angular UI 애플리케이션에서만 가능하다는 뜻
@CrossOrigin(origins = "http://localhost:4200")
@RestController
public class ExampleController {
// Controller 내용...
}
@CrossOrigin(origins = "*")
실제 애플리케이션에는 100개가 넘는 controller가 존재할 수 있다. 이 모든 controller에 일일히 들어가 설정해주는건 불가능하다.
따라서 Security FilterChain의 bean 생성 시 CORS 설정을 정의하는 방법이 있다.
이 CORS 설정을 사용해서 허용된 출처들을 브라우저에게 알려주는 것이다.
setAllowedOrigins()
메소드를 사용하여 통신을 허용할 출처들의 리스트를 적을 수 있다.
setAllowedMethods
메소드를 사용해 어떤 HTTP 메소드들을 받고 싶은지도 설정이 가능하다 (GET, POST, PUT, DELETE, PATCH )
만약 오로지 GET과 POST 요청만 받고 싶다면 alert()
메소드를 통해 설정할 수 있다.
setAllowCredentials()
메소드는 인증정보를 넘기고 받는 데에 동의한다고 말하는 것이다.
마지막으로 setAllowedHeaders()
메소드가 있다. 만약 다른 출처에서 오는 모든 종류의 헤더를 *
으로 정의하면 된다.
특정 종류의 헤더만 받고 싶다면 그 헤더들만 정의해도 된다.
setMaxAge()
메서드의 도움으로 3600초, 1시간으로 설정해놓았다.
브라우저에게 이 설정들을 1시간 동안 기억해두었다가 설정해둔 MaxAge에 따라서 1시간이 지나면 캐시로 변환되는 것이다.
브라우저는 대부분 pre-flight
요청을 보내며, 백엔드는 이를 통해 출처 확인 후 실제 요청을 받아들인다.
설정은 Spring Security에서 이를 고려해 브라우저에게 알린다.
이러한 설정을 통해 브라우저에게 "이러한 출처에서의 소통을 허용하고, 특정 조건에 따라 설정값을 기억해둬" 라고 알려주게 된다.
이를 통해 CORS 문제를 효과적으로 해결 가능하다.
CORS(Cross-Origin Resource Sharing)를 적용해보자.
CORS 설정은 웹 애플리케이션 내에서 controller level 또는 대외적으로 설정 가능하다.
대외적 설정이 권장되며, 이를 위해 ProjectSecurityConfig 클래스에 메소드를 추가한다.
package com.eazybytes.springsecsection2.config;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import javax.sql.DataSource;
import java.util.Collections;
@Configuration
public class ProjectSecurityConfig {
//람다(Lambda) DSL 스타일 사용을 권장
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Collections.singletonList("http://localhost:4200"));
config.setAllowedMethods(Collections.singletonList("*"));
config.setAllowCredentials(true);
config.setAllowedHeaders(Collections.singletonList("*"));
config.setMaxAge(3600L);
return config;
}
})).csrf((csrf) -> csrf.disable())
.authorizeHttpRequests((requests)->requests
.requestMatchers("/myAccount","/myBalance","/myLoans","/myCards","/user").authenticated()
.requestMatchers("/notices","/contact","/register").permitAll())
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
corsCustomizer
를 람다함수
형태로 구현하여 http.cors
를 구성했다. 인터페이스 CorsConfigurationSource
를 구현하는 익명 클래스를 생성하여 getCorsConfiguration()
메서드를 재정의했다.
getCorsConfiguration()
메소드에서 CorsConfiguration
객체를 생성하고 필요한 설정들을 추가한다.
setAllowOrigins()
,setAllowedMethods()
,setAllowCredentials()
, setAllowedHeaders()
,setMaxAge()
: 허용된 출처, 허용할 HTTP메서드, 인증 정보 주고받기 허용여부, 허용된 헤더 및 최대 기억시간 등을 설정했다.
여기서 익명 클래스란
Interface를 상속받는 Class를 정의하는 대신 생성자 인자로 new CorsConfigurationSource(){ 오버라이딩 해야 할 Method 구현 } 형태로 작성
Allowed Origins: 허용할 출처(도메인)를 설정한다.
Allowed Methods: 허용할 HTTP 메소드를 설정한다.
Allowed Headers: 허용할 헤더를 설정한다.
Allow Credentials: 인증 정보 허용 여부를 설정한다.
Max Age: CORS 설정 캐시로 사용할 시간을 설정한다.
백엔드 애플리케이션과 Angular UI 애플리케이션 간의 CORS 문제 해결을 위해 설정을 구현한다.
브라우저에서 발생한 pre-flight 요청을 확인하고 설정이 제대로 반영되었는지 확인한다.
페이지를 새로고침하여 CORS 관련 에러 없이 정상적으로 동작하는지 확인한다.
정상적으로 /notices
에 불러오도록 설정한 공지사항 정보들이 출력되는 것을 확인할 수 있다.
pre-flight
요청도 들어오는 것을 확인할 수 있다.
Cache-Control 헤더를 활용하여 브라우저가 일정 시간 동안 요청을 캐시하도록 설정한다.
캐시 기간 동안 동일한 요청에 대한 백엔드 호출을 최소화하여 성능을 향상시킨다.
CORS와 CSRF 설정은 서로 다르므로 설정 시 주의가 필요하다.
CORS 설정
은 백엔드의 역할
이며, 브라우저에게 pre-flight
요청을 만들도록 하는 것이 중요하다.
CSRF
는 브라우저의 보호 정책과 무관한 보안 공격으로, 백엔드에서의 보안을 위해 Spring Security에서 CSRF 보호를 제공한다.
@Configuration
@EnableWebSecurity
public class ProjectSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable(); // 주의: CSRF 완전 비활성화는 권장되지 않음
}
}
최근에 등록 API를 불러들여 새로운 유저를 추가하려 할 때 Spring Security가 이를 CSRF공격으로 받아들여 403 에러를 발생했었다.
그래서 지난 섹션들에서 CSRF 비활성화를 했었으나 사실은 Spring Security에선 CSRF를 비활성화하면 안된다.
CSRF를 완전히 비활성화하여 보안성을 저해하지 않도록 주의가 필요하다.
실제 프로젝트에서는 적절한 CSRF 해결책을 구현하여 보안을 강화해야 한다.
등록 API를 호출할 때 Spring Security에서 CSRF 공격으로 간주하여 403 에러가 발생한 문제를 해결하기 위해 CSRF 비활성화 설정이 수행되었다.
<CSRF 활성화했을 때 등록 API 호출 시 403 에러>
CSRF를 활성화하면 Spring Security가 GET 작업은 허용하나 데이터 업데이트나 추가 옵션은 허락하지 않는다.
CSRF는 해커들이 주로 활용하는 보안 위협으로, 사용자가 무심코 악의적인 동작을 수행하도록 유도한다.
주로 세션 ID나 쿠키와 같은 정보를 노리지 않고, 사용자의 무의식적인 행동을 통해 공격한다.
CSRF 또는 XRF에 대해 정리해보자.
사용자는 정상적으로 넷플릭스에 로그인하고, 서버에서 쿠키를 생성하여 브라우저에 저장한다.
참고로 netflix.com과 같은 이름의 도메인이 브라우저에 쿠키를 생성하면 다른 faceboot.com이나 amazon.com 같은 도메인들이 이 쿠키를 브라우저에서 훔쳐갈 수 없습니다.
브라우저는 할당된 오리지널 도메인에서만 쿠키를 고융하도록 설계되어 있다. 쿠키가 처음 만들어진 도메인 말이다. 이 상황에서 오리지널 도메인은 netflix.com이다.
사용자가 악성 웹사이트인 evil.com을 방문하고, 엔드유저는 다른 창이지만 여전히 동일한 브라우저로 접속한다.
악성 웹사이트는 사용자에게 유용한 정보와 현혹적인 제안이 담긴 웹 페이지를 띄운다.
예를 들어 "아이폰 90% 할인" 등과 같은 텍스트 말이다.
사용자가 유혹에 넘어가 링크를 클릭하면, 악성 웹사이트에 숨겨진 코드가 실행된다.
백엔드로 netflix.com
의 changeEmail
에 대한 POST 요청
을 생성하도록 사용자에게 양식을 작성하도록 유도한다.
해당 코드는 백엔드로 사용자의 계정에서 이메일을 변경하는 요청을 보낸다.
브라우저는 netflix.com 도메인의 쿠키 정보를 자동으로 해당 요청과 함께 전송하게 된다.
넷플릭스 서버는 CSRF 공격을 제대로 다루지 못하면, 이 요청을 유효한 것으로 간주하고 사용자의 이메일을 변경한다.
사용자는 이런 공격에 대해 알지 못하며, 해커가 사용자를 속여 웹사이트에 악영향을 끼치게 된다.
CSRF 공격은 사용자의 무의식적인 행동을 이용하여 보안을 침해하는 위험한 기술 중 하나이다.
사용자의 무의식적인 동작을 이용하여 보안을 침해하는 기술
세션 ID와 쿠키 등을 노리지 않고, 사용자를 유도하여 악의적인 행동을 수행하도록 한다.
백엔드 애플리케이션은 들어오는 HTTP 요청이 유효한 유저에게서 오는지 확인해야 한다.
안전한 랜덤 토큰으로, 웹 애플리케이션에서 CSRF 공격을 방지하기 위해 사용된다.
세션마다 고유하게 할당되어 해커의 예측이 어렵다.
로그인: 유저가 로그인하면 백엔드는 CSRF 토큰을 생성하여 쿠키로 브라우저에 전송.
악성 웹사이트 접속: 유저가 악성 웹사이트에 접속해도 CSRF 토큰이 함께 전송됨.
CSRF 공격 시도: 악성 코드에서 netflix.com의 이메일 변경을 시도하나, CSRF 토큰이 없으면 백엔드는 요청을 거부.
브라우저는 netflix.com으로 요청 시, 랜덤 CSRF 토큰과 인증 정보 관련 쿠키를 함께 전송한다.
CSRF 토큰 또한 쿠키로써 함께 보냈다.
하지만 백엔드에서 UI 애플리케이션으로 CSRF 토큰을 갖고 통신을 하는 데에는 수많은 방법들이 있지만 가장 흔한 방법은 CSRF 토큰을 쿠키 자체에 넣어서 전송하는 것이다.
그럼 브라우저는 넷플릭스 백엔드에서 2개의 쿠키를 받는다.
엔드 유저가 evil.com에 같은 브라우저 창으로 접속했다.
이번에도 evil.com이 넷플릭스 계정의 이메일을 변경하는 코드인 같은 웹 페이지를 보낸다. 즉, 여기엔 잠복된 악성 링크가 담겨있는 것이다.
이 잠복 링크 역시 유혹적이다. "아이폰 90% 할인 문구"를 그냥 지나칠 수 없다.
엔드 유저가 악성 코드를 클릭한다.
그리고 코드 뒤에서 해커가 html이든 java든 뭐든 사용해 엔드 유저의 이메일을 변경하기 위해 netflix.com에 요청을 보내려고 한다.
요청이 netflix.com으로 들어왔고 브라우저는 2개의 쿠키를 보낸다.
하나는 인증 쿠키, 하나는 CSRF 토큰 쿠키
이번에는 netflix.com 백엔드 서버가 403 에러와 함께 요청을 거부한다.
그리고 해커는 CSRF 공격을 실행하지 못한다.
브라우저가 2개의 쿠키를 보내는데 어떻게 netflix.com 백엔드 서버는 이를 알아보고 정당한 유저인지 해커인지 구분을 할까?
여기엔 속임수가 있다.
로그인 동작에서 언제든 CSRF 토큰을 받으면 그 토큰을 쿠키 안에 저장한다.
하지만 브라우저의 도움을 받아 기본 형식으로 백엔드에서 쿠키를 보내는 게 아니다. UI 애플리케이션 안에 작은 코드를 써서 쿠키를 보내려면 수동으로 코드를 읽어서 Header나 Body/Payload에 보내야 하고 아니면 백엔드 서버의 동의가 있어야 한다.
그리고 쿠키를 같은 도메인 netflix에서 읽기 때문에 JavaScipt로 쿠키를 읽을 수 있어야 하고, HTML 코드를 통해서는 해커가 evil.com에서 하던 것을 할 수 없다.
왜냐하면 evil.com에서 JavaScript 작업을 하려면 그는 evil.com의 JavaScript로 접속해야 하기 때문이다. 그러면 도메인이 CSRF 토큰의 쿠키에 접근하지 못한다.
제한이 생기면 헤커는 Header나 내용(Payload)안에 토큰을 넣어 전송할 수 없다.
헤커는 쿠키가 브라우저 백엔드에 자동으로만 전송되는 것에 의존했기 때문이다.
CSRF 토큰이 들어오지 않는 이상 브라우저는 요청을 수락하지 않을 것이며 UI 애플리케이션과의 동의가 된 내용(Payload)라도 있어야 한다.
웹 애플리케이션과 UI 애플리케이션 모두에서 CSRF 토큰을 관리하고 사용해야 한다.
백엔드와의 동의 없이는 쿠키를 사용한 CSRF 토큰 전송이 제한된다.
절대로 csrf().disable()를 사용하여 CSRF를 비활성화하지 말고, 적절한 대응 방법 활용 권장한다.
CSRF 공격에 대처하기 위해 CSRF 토큰을 도입하는 방법이 효과적이다.
Spring Security와 같은 보안 프레임워크를 활용하여 적용하는 것이 권장된다.
API 소개: Contact, Register와 같은 공공 API 생성. 공개 API로 누구나 등록 가능하다.
CSRF 공격 위험이 없는 이유: 민감한 정보가 없어 큰 위험이 없다.
Spring Security 설정: 공공 API임을 Spring Security에 알린다.
기본 CSRF 보호 비활성화: 불필요한 보호를 방지하기 위해 CSRF 보호를 적용시키지 말아야 한다.
에러 해결: 403 에러 발생 시, Spring Security와 통신하여 해결 필요하다.
CSRF 설정 필요성: 특정 공공 URL에서는 CSRF 보호가 필요 없는 경우에도 어느정도 설정 필요하다.
Security FilterChain 설정: CSRF 설정을 적용할 Security FilterChain bean 정의한다.
CORS 및 CSRF 설정 병합: CORS 설정 후, CSRF 설정과 병합하여 적용한다.
package com.eazybytes.springsecsection2.config;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import javax.sql.DataSource;
import java.util.Collections;
@Configuration
public class ProjectSecurityConfig {
//람다(Lambda) DSL 스타일 사용을 권장
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
//CorsConfigurationSource 인터페이스를 구현하는 익명 클래스 생성하여 getCorsConfiguration() 메소드 재정의
http.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
//`etCorsConfiguration() 메소드에서 CorsConfiguration 객체를 생성하고 필요한 설정들을 추가
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration config = new CorsConfiguration();
//허용할 출처(도메인)를 설정
config.setAllowedOrigins(Collections.singletonList("http://localhost:4200"));
//허용할 HTTP 메소드를 설정
config.setAllowedMethods(Collections.singletonList("*"));
//인증 정보 허용 여부를 설정
config.setAllowCredentials(true);
//허용할 헤더를 설정
config.setAllowedHeaders(Collections.singletonList("*"));
//CORS 설정 캐시로 사용할 시간을 설정
config.setMaxAge(3600L);
return config;
}
//공개 API에 대한 CSRF 보호
})).csrf((csrf) -> csrf.ignoringRequestMatchers("/contact", "/register"))
.authorizeHttpRequests((requests)->requests
.requestMatchers("/myAccount","/myBalance","/myLoans","/myCards","/user").authenticated()
.requestMatchers("/notices","/contact","/register").permitAll())
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Notices API는 왜 무시되지 않는가?
ignoringRequestMatchers("/contact", "/register")
CSRF를 활성화 시키면 유저 등록이 되지 않지만
`ignoringRequestMatchers("/contact", "/register")`를 추가함으로써 customer 등록 및 contact_messages 등록이 정상적으로 된다.백엔드와 프론트엔드 간의 CSRF 토큰 사용을 통한 보안 강화
Angular UI 애플리케이션에서 백엔드로의 안전한 요청 전송 방법
반환 클래스가 SecurityFilterChain
인 defaultSecurityFilterChain()
메소드 안에 Spring Security 설정을 하기 전에 CsrfTokenRequestAttributeHandler
객체를 생성해준다.
CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
CsrfTokenRequestAttributeHandler
클래스의 목적은 CsrfTockenRequestHandler
를 구현하기 위함이다.
이 클래스는 CSRF 토큰이 요청 속성으로써 활성화될 수 있도록 도와주고 헤더로든 변수로든 토큰 값을 해결한다.
Spring Security가 CSRF 토큰 값을 생성하고 값이 처리되거나 UI 애플리케이션에게 헤더 또는 쿠키의 값을 전달하기 위해서는 이 AttributeHandler
클래스 도움을 받아야 한다.
requestHandler.setCsrfRequestAttributeName("_csrf");
해당 클래스의 메서드 setCsrfRequestAttributeName()
에게 ArributeName
을 _csrf
라고 전달한다.
이를 언급하지 않아도 기본적으로 CsrfTokenRequestAttributeHandler
가 같은 이름을 생성하겠지만 가독성을 위해 적어두었다.
csrf()
는 두 가지 종류로 나뉜다.
두 번째 csrf()
는 CsrfConfigurer
을 수용할 것이고, method input 변수로써의 역할 가진다.
이 두 번째 메서드를 선택하였다.
이 메소드에게 모든 CSRF 설정들을 전달해야 한다.
CsrfConfigurer
객체를 사용해 우선 CsrfTockenRequestHandler
를 불러와야 한다.
위에서 생성한 requestHandler
객체를 전달한다.
그 다음 ignoringRequestMatchers
를 불러온다. 여기엔 공공 API URL들을 언급한다. (이전에 설명) 그래야 해당 URL에 CSRF 보호가 동작하지 않는다.
다음 csrfTokenRepository
라는 메서드를 불러온다.
이 메소드에게는 CookieCsrfTokenRepository.withHttpOnlyFalse()
코드를 전해준다.
.csrf((csrf) -> csrf.csrfTokenRequestHandler(requestHandler).ignoringRequestMatchers("/contact", "/register")
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
CookieCsrfTokenRepository
는 CSRF 토큰을 쿠키로 유지하는 역할이고, 헤더에서 이 이름으로 찾는다.
AngularJS와 똑같은 규칙을 따른다.
DEFAULT_CSRF_COOKIE_NAME
의 이름이 쿠키에 사용될 이름이다.
DEFAULT_CSRF_HEADER_NAME
의 이름은 헤더에 사용될 이름이다.
UI 어플리케이션으로 보내지게 된다.
쿠키들이 생성될 때 주로 백엔드 애플리케이션들이 UI 애플리케이션에서 쿠키 값을 읽어내는 옵션이 있고 아니면 UI 애플리케이션이나 그 안에 JavaSrciprt 코드가 쿠키 값을 읽어내지 못하게 할 수 있다.
하지만 withHttpOnlyFalse()
메소드의 도움으로 Spring Security 프레임워크에게 "HttpOnlyFalse를 설정으로 쿠키를 생성해줘. 그래야 Angular 앱 안에 있는 JavaScript 코드가 쿠키 값을 읽을 수 있으니까" 라고 말한다.
CsrfTokenRepository
에는 다음과 같이 명시되어 있다.
"XSRF-TOKEN"이라는 이름의 쿠키에 CSRF 토큰을 저장하고 AngularJS의 규칙을 따라 "X-XSRF-TOKEN" 헤더에서 읽는다. AngularJS와 함께 사용할 때 withHttpOnlyFalse()를 사용해야 한다.
다음으로 해야 할 것은 백엔드 애플리케이션에서 우선 첫 로그인 후 UI 애플리케이션에게 헤더와 쿠키 값을 보내야한다. 그렇지 않으면 UI가 CSRF 토큰 값을 알 수 없다.
Spring Seruciry가 UI 애플리케이션의 반응으로 생성된 토큰 값을 보내야 하는 이유이다.
CSRF 토큰을 보내려면 UI 애플리케이션으로 보내게 될 모든 응답들에 filter 클래스
를 생성해야 한다.
package com.eazybytes.springsecsection2.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
public class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//HttpServletRequest에서 가능한 CsrfToken을 읽음
//언제든 백엔드에 토큰 값이 생성되면 요청 속성으로써 사용 가능해질 것이며 읽어내서 CSRF 토큰의 객체에게 변환을 해줌
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
//csrfToken 안에 헤더 이름이 있는지 확인, null이 아닐 경우 CSRF 토큰을 생성함
if(null != csrfToken.getHeaderName()){
//응답 헤더에 csrfToken의 hearderName과 token 값을 주입
response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken());
}
//해당 응답이 FilterChain 안에 있는 다음 필터에게 전달
filterChain.doFilter(request, response);
}
}
자체 로직을 적었다.
요청 중 토큰 값이 생성되면, 응답에 CsrfToken의 headerName(=getHeaderName)과 token(=getToken) 값을 넣어 다음 필터에게 전달해주는 과정이다.
UI 애플리케이션에게 응답을 보내는 것이지만 CSRF 토큰값은 헤더 안에 있다.
그렇다면 "쿠키는 어떡하지?" 라는 생각이 들 수 있다.
헤더만 보내고 쿠키는 보내지 않는다.
CSRF 토큰 값을 채우면 응답 헤더로써 Spring Security는 CSRF 쿠키를 보내고 동일한 것을 브라우저나 UI 애플리케이션 응답 보내는 것에 신경을 쓴다.
필터가 Spring Security와 통신할 수 있도록 코드를 적어주자.
.csrf((csrf) -> csrf.csrfTokenRequestHandler(requestHandler).ignoringRequestMatchers("/contact", "/register")
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
addFilter()
메소드를 불러오고 생성한 필터 CsrfCookieFilter
를 전달한다. 그리고 두 번째 매개변수 인자는 BasicAuthenticationFilter.class
이다.
BasicAuthenticationFilter.class
는 HTTP Basic Authentication을 사용할 때 중요한 역할을 한다.
"BasicAuthenticationFilter
를 먼저 실행하고 CsrfCookieFilter
를 실행해줘"
라는 뜻으로 해석할 수 있다.
BasicAuthenticationFilter
후에만 로그인 동작이 완료될 수 있고, 로그인 동작이 완료되면 CSRF 토큰이 생성된다.
CSRF와는 관련이 없지만 한 가지를 더 추가해야 한다.
이 설정의 목적은 전에 브라우저를 통해 API에 접속하려고 할 때 백엔드 REST API들과 직접적으로 브라우저를 통해 접속하고 거기에 Spring Security에 만들어진 로그인에 자격증명을 입력하고 뒤에서는 JSESIONID가 생성되었다.
같은 JESSIONID를 갖고 모든 후속 요청들을 인증 정보 없이 접근하려 하는 것이다.
하지만 백엔드와 클라이언트가 분리된 (서버 사이드 방식이 아닌 클라이언트 사이드) UI 애플리케이션을 사용할 것이기 때문에 로그인해서 REST API들에 접근하려면 Spring Security에게 "내가 만든 sessionManagement
를 따라서 JSESIONID를 만들어줘"라고 한다.
이 설정은 Spring Sercurit에게 "첫 로그인이 완료되면 항상 JSESIONID를 생성해줘" 라고 하는 것이다.
동일한 JSESIONID가 UI 애플리케이션에 보내지고 UI 애플리케이션은 첫 로그인 이후에 만들어지는 후속 요청들을 활용할 수 있게 된다.
이 두 줄 없이는 매번 보안 API를 접근할 때마다 Angular 앱에서 자격증명을 입력해야 한다.
http.securityContext((context) -> context.requireExplicitSave(false))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
requireExplicitSave()
를 사용해서 Spring Security에게 "내가 SecurityContextHolder
안에 있는 인증 정보들을 저장하는 역할을 맡지 않을 거야" "프레임워크들이 대신 수행하게 해줘" 라고 한다.
기본적으로 이 값은 true이다. 이 기본설정들을 덮어씌움으로써 JSESSIONID를 생성하는 역할을 주는 것이다. 그리고 프레임워크의 SecurityContextHolder
안에 인증정보를 저장한다.
백엔드만 CSRF 토큰 값을 UI 애플리케이션에 보낼 수 있게 활성화됐고, UI 애플리케이션도 CSRF 토큰 값을 받고 나중에 있는 후속 요청에 동일한 CSRF 토큰 값을 보낼 수 있어야 한다.
import { getCookie } from 'typescript-cookie';
..(중략)
//Sping Boot에서 온 'XSRF-TOKEN' 이름의 쿠키를 읽는다.
let xsrf = getCookie('XSRF-TOKEN')!;
//SessionStorage에 저장, 속성 이름은 "XSRF-TOKEN"로 둔다.
window.sessionStorage.setItem("XSRF-TOKEN",xsrf);
로그인이 동작되는 파일은 login.component.ts
이다.
여기서 Spring Boot에서 온 XSRF-TOKEN
이름의 쿠키를 읽고, sessionStorage
에 해당 이름과 값으로 저장한다.
/**
* 웹 애플리케이션에서 후속 요청을 생성하고, 백엔드로 보낼 때마다 CSRF 토큰을 보내야 한다.
*/
//sessionStorage에서 'XSRF-TOKEN'이름을 찾아 해당 값을 xsrf에 대입
let xsrf = sessionStorage.getItem('XSRF-TOKEN');
//xsrf가 null이 아니라면
if(xsrf){
//httpHeaders에 헤더 이름이 `X-XSRF-TOKEN`인 xsrf를 추가
httpHeaders = httpHeaders.append('X-XSRF-TOKEN',xsrf);
}
로그인 시 Spring Boot에서 Angular로 들어오는 처리를 했다면 반대로 Angular에서 Spring Boot로 응답해주는 후속처리도 해줘야 한다.
이전에 저장한 sessionStorage
에서 XSRF-TOKEN
이름을 찾아 해당 값을 xsrf
에 대입한 후, xsrf
가 null이 아니라면 헤더 이름을 X-XSRF-TOKEN
, 값은xsrf
로 해서 httpHeaders
에 추가해준다.
DB에 있는 Customer를 기준으로 login 테스트를 진행해본 결과 정상적으로 로그인되는 것을 확인할 수 있다.
처음엔 로그인 페이지에서 자격증명을 입력하려 할때 login.component
의 함수 validateUser()
를 불러온다. 내부 로직에서 login.service.ts
의 validateLoginDetails
함수가 동작하여 여기서 REST API를 불러오려고 한다. -> /user
ProjectSecurityConfig
에서 /user
는 보호되는 경로로 접근하려고 하기 때문에 Spring Security가 이 자격증명을 받음으로 사용자를 인증하기 위해 인증과정을 수행한다.
그 다음엔 사용자를 LoginController
에 있는 /user
로 이동시킨다.
여기서 하는 일은 로그인 사용자의 이름(=여기선 이메일)을 알아내고 Customer
객체를 반환한다.
그 전에 EazyBankUsernamePwdAuthenticationProvider
클래스를 정의하며 authenticate()
메소드 안에 외부에서 요청 인증이 온 유저와 DB안에 있는 유저의 정보를 비교한다. 여기서 인증이 성공하면 UsernamePasswordAuthenticationToken
에 username과 pwd, authorities를 주입시켰다. 이는 대부분 사용되는 인증 토큰으로 Authentication
이 반환값이며 사용자 인증을 담당한다.
따라서 사용자 인증이 완료된 후 LoginController
에 Authentication
객체를 사용하는 것을 명심하자.
로그인 완료 이후, dashboard
에 보여지는 Minji Kim
이라는 이름과 권한인 user
가 출력되는데 이는 SpringBoot에서 요청된 데이터를 가지고 응답인 Customer
객체를 Angular로 다시 보내면서 출력되는 것으로 보인다.
Spring Security는 어떻게 엔드 유저가 보낸 자격증명을 읽을 수 있는 것인가?
이전에 Spring Security의 로그인 페이지와 username, password 와 같은 속성을 UsernamePasswordAuthenticationFilter
의 도움으로 읽어냈었다.
이 상황에선 엔드 유저의 자격증명을 interceptor 클래스
인 app.request.interceptor.ts
에서 보내는 것이며 Authorization 헤더
와 함께 요청을 보낸다.
Authorization 헤더
안에는 간단한 형식의 authentication
으로 자격증명들을 넘겨준다. HTTP Basic Authentication
을 사용할 때면 Spring Security 안에 사용되는 중요한 필터가 하나 더 있다.
바로 BasicAuthenticationFilter
이다.
HTTP Basic Authentication
을 처리하는 역할을 한다.
이 필터는 클라이언트가 보낸 요청의 헤더에서 사용자 이름과 비밀번호를 추출하고, 이를 이용하여 사용자를 인증한다.
doFilterInternal
메서드는 BasicAuthenticationFilter
클래스의 메서드 중 하나로, 실제로 인증 과정을 처리한다.
클라이언트의 요청을 받으면 이 메서드가 호출되어 사용자 이름과 비밀번호를 추출하고, 이 정보를 사용하여 사용자를 인증한다.
만약 인증에 성공하면 요청을 계속 진행시키고, 실패하면 인증 오류를 반환한다.
주요 작업으로는 CSRF 토큰과 관련된 변경사항을 검증하고, 보호된 API로의 전환 해보자
예시로 happy@example.com과 12345를 사용하여 로그인을 시도한다.
로그인 후 대시보드로 이동하여 Account 탭을 누르면, 고객 정보가 표시된다.
특정 정보들은 account 테이블에서 가져오고, 다른 정보들은 customer 테이블에서 가져온다.
Balance 탭을 클릭하면 현재 잔고와 거래내역이 표시된다.
거래내역에는 출금과 입금 내역이 포함된다.
Loans와 Cards 탭을 통해 해당 정보들을 확인할 수 있다.
이들은 모두 해당 은행에서 관리되는 정보이다.
보안된 API 중 하나를 POST 요청으로 불러와서 CSRF 체크를 수행해보자
이전에 공공 API로 사용했던 Contact us API
를 보호된 API로 변경하고, CSRF 토큰 작업을 확인해보자.
requestMatchers(...).permitAll()
에 속해 있던 /contact
를 requestMatchers(...).authenticated()
에 적어주고, csrf
관련 코드를 지워 csrf
가 기본 적용된 모습으로 변경하였다.
로그인 후 /contact
에 접근했을 때 403 에러
를 만나기 위해 일단 이렇게 변경하였다.
Angular의 header.component.html
파일의 <li *ngIf="user.authStatus!='AUTH'" routerLinkActive="active"><a routerLink="/contact">Contact US</a></li>
도 user.authStatus!='AUTH'
를 user.authStatus=='AUTH'
로 변경해준다.
dashboard.service.ts
엔 모든 REST API들이 백엔드에서 불러와지고 있다.
하지만 withCredentials:true
로 설정하면 HTTP 요청을 보낼 때 브라우저에 쿠키를 포함하도록 지시한다. 일반적으로 브라우저는 다른 도메인으로의 요청에서는 쿠키를 보내지 않지만, 이 옵션을 사용하면 브라우저가 쿠키를 해당 요청에 포함하여 보내게 된다.
즉, 이 서비스의 모든 HTTP 요청은 사용자의 인증 상태를 유지하기 위해 필요한 세션 쿠키 등의 인증 정보를 포함하여 보내게 된다. 이것은 사용자가 로그인한 상태를 유지하고 있어야만 접근할 수 있는 보호된 API에 대한 요청을 가능하게 한다.
따라서 Contact API 또한 withCredentials:true
설정을 추가해줬다.
(기존엔 미포함 되어 있었음)
로그인 후 Contact Us
에 접근하여 필드를 모두 채우고 버튼을 눌러 post 요청
을 보냈더니 403 에러
가 뜬다. (권한 접근 금지 에러)
왜냐하면 현재 Contact API
가 보호된 API로 실행 중이기 때문이다.
그리고 백엔드에서 CSRF 토큰 관련 구현을 전혀 하지 않았기 때문이다.
이전에 CSRF 설정을 했던 것을 다시 불러와, CSRF로부터 무시하라고 적었던 ignoringRequestMatchers()
..리스트에 /contact
만 지우고 다시 빌드한다.
그리고 다시 실행하여 로그인 후 쿠키 안을 살펴보면 CSRF 토큰
을 확인할 수 있다.
지금은 JSESSIONID
와 함께 백엔드에서 XSRF 토큰
을 받는 중이다.
Contact Us에 필드를 채워 버튼을 누르면 정상적으로 등록이 되는 것을 확인할 수 있다.
CSRF 토큰을 POST 요청으로 보내고 백엔드에는 아무 문제가 없기 때문에 보내려는 요청이 잘 처리되었다.
테스트가 끝났으니 이전 코드로 다 되돌려놓자 (POST Method 보호된 API에서 이전에 구성한 POST Method 공공 API로 변경)